wechat-official-account

v2026.09.24

Generate articles, manage drafts, publish to «发表记录», send customer-service messages, manage menus and pull stats on a WeChat Official Account (微信公众号 / 服务号 / 订阅号) via the WeChat MP server-side API. Use when the user mentions 公众号, 服务号, 订阅号, mp.weixin.qq.com, AppID/AppSecret of a WeChat Official Account, or asks to draft / publish / send a customer message via WeChat.

GitHub
Install command
npx skhub add acedatacloud/wechat-official-account
Markdown
SKILL.md

We drive the WeChat MP server-side API with curl + jq. Unlike OAuth-bearer connectors, WeChat MP uses a two-step flow:

  1. Exchange AppID + AppSecret for an access_token (TTL 7200s, global limit ≈ 2000 calls/day per app).
  2. Pass that access_token as a query string parameter on every other call.

The user's credentials are in $WECHAT_APP_ID and $WECHAT_APP_SECRET. Never log or echo $WECHAT_APP_SECRET — treat it like a password.

The WeChat MP API returns standard JSON. Errors are returned with HTTP 200; the body looks like {"errcode": 40013, "errmsg": "invalid appid"}. errcode == 0 means success — show the original errmsg to the user verbatim on any non-zero code.

Important constraints — surface these to the user before they're surprised

  • IP whitelist: every API call's source IP must be in this app's IP whitelist (公众平台 → 设置与开发 → 基本配置 → IP 白名单). If you see errcode 40164 ("invalid ip"), the worker's egress IP isn't whitelisted; tell the user to add the IP shown in errmsg and retry.
  • Verified account required for publishing: as of 2025-07, only verified (已认证) corporate-subject accounts can call freepublish/* and mass/*. Personal-subject accounts and unverified corporate accounts get a permission error. Drafts (draft/*) and customer messages (message/custom/*) usually work without verification.
  • Group-send quota is harsh: 服务号 = 4 sends/month, 订阅号 = 1 send/day. Treat freepublish/submit and mass/sendall like a destructive operation — always confirm with the user before calling them, even if instructions say "publish it". Default to creating a draft and pasting the draft URL.
  • Customer-service window is 48 hours: message/custom/send only works for a follower whose openid interacted with the account in the last 48 hours. Outside that window you get errcode 45015.

Recipes

Step 0 — get an access_token (do this first, cache the result)

# Cache to /tmp so subsequent calls in the same session reuse it.
TOKEN_CACHE="/tmp/wx-mp-token-${WECHAT_APP_ID}.json"

# Reuse cached token if it's still valid (we conservatively refresh
# 5 minutes early to avoid edge-of-window failures).
NOW=$(date +%s)
if [ -f "$TOKEN_CACHE" ] && [ "$(jq -r '.exp_at // 0' "$TOKEN_CACHE")" -gt "$((NOW + 300))" ]; then
  WECHAT_ACCESS_TOKEN=$(jq -r '.access_token' "$TOKEN_CACHE")
else
  RESP=$(curl -sS "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${WECHAT_APP_ID}&secret=${WECHAT_APP_SECRET}")
  WECHAT_ACCESS_TOKEN=$(echo "$RESP" | jq -r '.access_token // empty')
  if [ -z "$WECHAT_ACCESS_TOKEN" ]; then
    echo "Failed to fetch access_token: $RESP" >&2
    exit 1
  fi
  EXPIRES=$(echo "$RESP" | jq -r '.expires_in // 7200')
  jq -nc --arg t "$WECHAT_ACCESS_TOKEN" --argjson e "$((NOW + EXPIRES))" \
    '{access_token:$t, exp_at:$e}' > "$TOKEN_CACHE"
fi
echo "OK token=${WECHAT_ACCESS_TOKEN:0:8}…"

If the response is {"errcode": 40164, ...} (invalid IP) or {"errcode": 40013, ...} (invalid appid) — surface that error to the user; it means the connector setup itself is wrong, not the request.

Verify the connection (always run this once before complex operations)

# Pull basic public-account self-info; cheapest call that proves the token works.
curl -sS "https://api.weixin.qq.com/cgi-bin/account/getaccountbasicinfo?access_token=${WECHAT_ACCESS_TOKEN}" | jq

Upload an image to use inside an article body (returns a public URL)

This is for <img src="..."> tags inside the article HTML. The returned url is hosted by Tencent and works in published articles.

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${WECHAT_ACCESS_TOKEN}" \
  -F "media=@/path/to/your-image.jpg"
# → {"url": "http://mmbiz.qpic.cn/..."}

Limits: ≤ 1 MB, JPG/PNG only, no count limit on this endpoint.

Upload a thumbnail (needed as the article cover)

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${WECHAT_ACCESS_TOKEN}&type=thumb" \
  -F "media=@/path/to/cover.jpg" | jq '{media_id, url}'
# → {"media_id": "MEDIA_ID", "url": "http://mmbiz.qpic.cn/..."}

The media_id you get back is what you pass as thumb_media_id when creating a draft. Recommended cover dimensions: 900×500 px, JPG, < 64 KB ideally.

Create a draft (the safe default — do not directly publish)

TITLE="Q1 product update"
AUTHOR="Acme Inc."
THUMB_MEDIA_ID="MEDIA_ID_FROM_PREVIOUS_STEP"
CONTENT_HTML='<p>欢迎关注我们的最新动态。</p><p><img src="http://mmbiz.qpic.cn/..."></p><p>更多内容请见底部「阅读原文」。</p>'
DIGEST="Q1 has been a wild ride — here's what shipped."
SOURCE_URL="https://example.com/q1-recap"   # optional; populates 「阅读原文」, leave empty to omit

PAYLOAD=$(jq -nc \
  --arg title       "$TITLE" \
  --arg author      "$AUTHOR" \
  --arg thumb       "$THUMB_MEDIA_ID" \
  --arg content     "$CONTENT_HTML" \
  --arg digest      "$DIGEST" \
  --arg source_url  "$SOURCE_URL" \
  '{articles: [{
    article_type: "news",
    title: $title,
    author: $author,
    thumb_media_id: $thumb,
    content: $content,
    digest: $digest,
    content_source_url: $source_url,
    need_open_comment: 0,
    only_fans_can_comment: 0
  }]}')

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$PAYLOAD" | jq
# → {"media_id": "DRAFT_MEDIA_ID"}

Tell the user: "Draft created. Open https://mp.weixin.qq.com → 草稿箱 to review and tap «发表» from there." — that's the safest path because the user controls the publish click in WeChat's own UI.

List drafts (newest first)

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/draft/batchget?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw '{"offset": 0, "count": 20, "no_content": 1}' \
  | jq '.item[] | {media_id, update_time, title: .content.news_item[0].title}'

Get the full content of one draft

DRAFT_MEDIA_ID="..."
curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/draft/get?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg m "$DRAFT_MEDIA_ID" '{media_id: $m}')" | jq

Update an existing draft

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/draft/update?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg m "$DRAFT_MEDIA_ID" --arg t "Updated title" --arg c "<p>new body</p>" --arg th "$THUMB_MEDIA_ID" '
    {media_id: $m, index: 0, articles: {
      title: $t, content: $c, thumb_media_id: $th
    }}')" | jq

Publish a draft to «发表记录» — DESTRUCTIVE, confirm before calling

# Eats one of the monthly publish slots. Always echo back to the user
# what's about to go live and require an explicit "yes" confirmation
# in conversation BEFORE invoking this.
curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg m "$DRAFT_MEDIA_ID" '{media_id: $m}')" | jq
# → {"errcode": 0, "msg_data_id": ..., "publish_id": "..."}

Then poll publish status (publishing is async, takes ~5-30 seconds):

PUBLISH_ID="..."
curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/freepublish/get?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg p "$PUBLISH_ID" '{publish_id: $p}')" | jq '{publish_status, fail_idx, article_id, article_url: .article_detail.item[0].article_url}'
# publish_status: 0 = success, 1 = publishing, 2 = original-check-failed,
#                 3 = failed, 4 = published-but-removed, 5 = unverified-removed

When publish_status == 0, return article_detail.item[0].article_url to the user — that's the canonical https://mp.weixin.qq.com/s/... URL.

List already-published articles

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/freepublish/batchget?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw '{"offset": 0, "count": 20, "no_content": 1}' \
  | jq '.item[] | {article_id, update_time, title: .content.news_item[0].title, url: .content.news_item[0].url}'

Send a customer-service message (one specific follower, 48h window)

OPENID="oXXXXXXXXXXXXXXXXX"   # the recipient follower's openid
TEXT="Hi! Your subscription has been renewed. Thanks for sticking with us."

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg u "$OPENID" --arg t "$TEXT" '
    {touser: $u, msgtype: "text", text: {content: $t}}')" | jq

errcode 45015 = recipient hasn't messaged the account in the last 48h — explain that to the user; there's nothing the API can do about it.

To send an article card via customer message, change to msgtype: "mpnews" with mpnews: {media_id: "..."} (use material/add_news to first create a permanent news media_id).

Pull follower-growth + read stats

BEGIN="2026-04-25"
END="2026-05-01"   # max 7-day window for getusersummary

# New / unsubscribed / cumulative followers per day
curl -sS -X POST \
  "https://api.weixin.qq.com/datacube/getusersummary?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg b "$BEGIN" --arg e "$END" '{begin_date: $b, end_date: $e}')" \
  | jq '.list[] | {date: .ref_date, new: .new_user, lost: .cancel_user, source: .user_source}'

# Article reads per day (max 3-day window for getuserread)
curl -sS -X POST \
  "https://api.weixin.qq.com/datacube/getuserread?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg b "$BEGIN" --arg e "$BEGIN" '{begin_date: $b, end_date: $e}')" | jq

datacube/* requires an authenticated (已认证) account with the data-cube permission.

Manage the bottom menu

# Read current menu
curl -sS "https://api.weixin.qq.com/cgi-bin/menu/get?access_token=${WECHAT_ACCESS_TOKEN}" | jq

# Replace the menu (max 3 top-level buttons; each top-level button can have ≤ 5 sub-buttons)
MENU=$(jq -nc '{
  button: [
    {type: "click", name: "今日推荐", key: "DAILY_REC"},
    {name: "更多",
     sub_button: [
       {type: "view", name: "官网",   url: "https://example.com"},
       {type: "view", name: "联系我们", url: "https://example.com/contact"}
     ]}
  ]
}')
curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$MENU" | jq
# → {"errcode": 0, "errmsg": "ok"}

Get the follower list (paginated by next_openid)

NEXT=""
curl -sS "https://api.weixin.qq.com/cgi-bin/user/get?access_token=${WECHAT_ACCESS_TOKEN}&next_openid=${NEXT}" \
  | jq '{total, count, openids: .data.openid, next: .next_openid}'
# Loop: pass the returned `next_openid` as NEXT until count < 10000.

For each openid, batch-fetch profile (≤ 100 per call):

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/user/info/batchget?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw '{"user_list": [{"openid": "OPENID1", "lang": "zh_CN"}]}' \
  | jq '.user_info_list[] | {openid, nickname, subscribe_time, tagid_list}'

HTML rules for article content

The content field in draft/add accepts a subset of HTML, not full web HTML:

  • Allowed: <p>, <span>, <strong>, <em>, <a href>, <img src>, <br>, <h1>–<h3>, <ul>/<ol>/<li>, <blockquote>, <section>.
  • Inline styles only (<p style="...">) — no <style> blocks, no <link> to external CSS.
  • All <img> src URLs must be either previously-uploaded mmbiz.qpic.cn URLs (from media/uploadimg) or already-published mmbiz URLs. WeChat strips images hosted on third-party domains.
  • Total content size limit: ~ 20 K characters (HTML included).

Common errcode cheat-sheet

errcodemeaningwhat to tell the user
0success—
40001invalid access_tokenToken expired mid-call; flush $TOKEN_CACHE and retry
40013invalid appidThe AppID in the connector is wrong — re-add the connection
40164source IP not in whitelistAdd the IP shown in errmsg to 公众平台 → 设置与开发 → 基本配置 → IP白名单
41001missing access_tokenBug — you forgot to pass ?access_token=...
45009API daily quota exceededTry again tomorrow; the per-app daily quota was hit
45015response message out of 48hCustomer-service window has closed for this openid; can't recover via API
48001api unauthorizedAccount doesn't have permission for this endpoint (e.g. publish without 认证); see the doc URL in errmsg
61450system errorTencent-side flake; retry once after a 1-second backoff

Why we use bare curl + jq (not an SDK)

Skill bodies must be self-contained shell. Calling pip install wechatpy is not allowed in the sandbox, and the API surface here is small (15-ish endpoints) and stable since 2018. The python SDKs (wechatpy, werobot) are useful references for parameter shapes, but every recipe above is a direct call to the documented endpoint — the SDKs are just thin wrappers around the same JSON.

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

NOASSERTION

Source path

skills/wechat-official-account

Default branch

main

Latest commit

57cc298

Tree SHA

acaa402