Agent skill

WeChat Pay Webhooks Skill

Receive and verify WeChat Pay (APIv3) webhook notifications. Use when setting up WeChat Pay webhook handlers, debugging Wechatpay-Signature RSA-SHA256 verification, decrypting the AEAD_AES_256_GCM encrypted resource, or handling payment and refund events like TRANSACTION.SUCCESS, REFUND.SUCCESS, and REFUND.CLOSED.

Install this skill

npx skills add hookdeck/webhook-skills --skill wechat-webhooks


When to Use This Skill

  • How do I receive WeChat Pay webhooks (APIv3 notifications)?
  • How do I verify the Wechatpay-Signature header?
  • How do I decrypt the encrypted resource in a WeChat Pay notification?
  • How do I handle TRANSACTION.SUCCESS or REFUND.SUCCESS events?
  • Why is my WeChat Pay signature verification failing?

How WeChat Pay Notifications Work

WeChat Pay APIv3 does not use HMAC or the Standard Webhooks spec. Each notification is:

  1. Asymmetrically signed (SHA256withRSA) — verify with the WeChat Pay platform public key, matched by the Wechatpay-Serial header, over the message "{timestamp}\n{nonce}\n{body}\n".
  2. Separately encrypted — the resource object is AEAD_AES_256_GCM ciphertext. Decrypt resource.ciphertext with your 32-byte APIv3 key to recover the transaction/refund JSON.

The signed body is the raw request bytes (the ciphertext envelope), so verify first, then decrypt. Always use the raw request body — never JSON.parse before verifying.

Verification (core)

const crypto = require('crypto');

// 0. Select the platform public key by Wechatpay-Serial. WeChat publishes new
//    certificates ~24h before signing with them, so an unpinned rotation must
//    fail with its own error, not a generic "invalid signature".
const PLATFORM_KEYS = JSON.parse(process.env.WECHAT_PAY_PLATFORM_KEYS || '{}');

function selectPlatformKey(serial) {
  const key = PLATFORM_KEYS[serial];
  if (!key) {
    throw new Error(
      `No platform key configured for serial ${serial} — ` +
      'fetch the current certs via GET /v3/certificates and add it'
    );
  }
  return key;
}

// 1. Verify the RSA-SHA256 signature over "{timestamp}\n{nonce}\n{body}\n"
function verifySignature(timestamp, nonce, rawBody, signatureB64, platformPublicKey) {
  const message = `${timestamp}\n${nonce}\n${rawBody}\n`;
  const verifier = crypto.createVerify('RSA-SHA256').update(message, 'utf8');
  try {
    return verifier.verify(platformPublicKey, signatureB64, 'base64');
  } catch {
    return false; // malformed key/signature
  }
}

// 2. Decrypt resource.ciphertext (AEAD_AES_256_GCM) with your 32-byte APIv3 key
function decryptResource({ ciphertext, nonce, associated_data }, apiV3Key) {
  const buf = Buffer.from(ciphertext, 'base64');
  const decipher = crypto.createDecipheriv('aes-256-gcm', apiV3Key, nonce);
  decipher.setAuthTag(buf.subarray(buf.length - 16));           // last 16 bytes = auth tag
  if (associated_data) decipher.setAAD(Buffer.from(associated_data));
  const plain = Buffer.concat([decipher.update(buf.subarray(0, -16)), decipher.final()]);
  return JSON.parse(plain.toString('utf8'));
}

Also reject notifications whose Wechatpay-Timestamp is more than 5 minutes from now (replay protection).

For complete handlers with route wiring, event dispatch, and tests, see:

Common Event Types

EventTriggered When
TRANSACTION.SUCCESSA payment completed successfully
REFUND.SUCCESSA refund was processed successfully
REFUND.CLOSEDA refund was closed (not completed)

This skill targets the Global (English) APIv3 endpoint, which defines only these three events. The mainland-China-only REFUND.ABNORMAL event is not part of the global endpoint.

Acknowledging Notifications

Respond with HTTP 200 or 204. A success body is optional, but the documented form is:

{ "code": "SUCCESS", "message": "OK" }

On any failure (bad signature, processing error) return a non-2xx status. WeChat Pay retries on a schedule (~15s, 15s, 30s, 3m, 10m, 20m, 30m … up to ~24h), so handle notifications idempotently and re-verify the order amount before fulfilling.

Environment Variables

# Recommended: platform public keys (PEM) keyed by certificate serial, as JSON
WECHAT_PAY_PLATFORM_KEYS='{"serial_a":"-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"}'
# 32-character APIv3 key used to decrypt resource.ciphertext (AES-256-GCM)
WECHAT_PAY_API_V3_KEY=your_32_character_apiv3_key_here

# Single-key alternative — folded into the map above when both are set
WECHAT_PAY_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
WECHAT_PAY_PLATFORM_SERIAL=your_platform_cert_serial

The platform public key / certificate is downloaded and rotated by serial number via GET /v3/certificates (itself AES-GCM encrypted). WeChat publishes new certificates ~24h ahead of use, so a single pinned key rejects every notification the moment a rotation lands — key your store by Wechatpay-Serial and refresh it (e.g. every 12h). See references/setup.md.

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 wechat --path /webhooks/wechat

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 3, 2026

View on GitHub →