A Practical Guide to Calling WeChat Official Account APIs from Spring Boot
Configuration
Add the following properties to application.yml to store WeChat credentials:
wechat:
appid: your_app_id
secret: your_app_secret
token: your_verification_token
refreshExpire: 120
The token is used during server verification, and refreshExpire controls how long the access token is kept in the cache.
Server Verification
When you configure the server URL in the WeChat official account dashboard, WeChat sends a GET request to verify ownership. You need an endpoint that checks the signature and returns the echostr parameter.
@RestController
@RequestMapping("/wechat")
public class WechatCallbackController {
@Value("${wechat.token}")
private String verifyToken;
@GetMapping("/verify")
public String verifySignature(@RequestParam("signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr) {
if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
return "";
}
String[] items = {verifyToken, timestamp, nonce};
Arrays.sort(items);
String raw = String.join("", items);
String hashed = DigestUtils.sha1Hex(raw);
if (hashed.equals(signature)) {
return echostr;
}
return "";
}
}
Use any SHA-1 utility (e.g., DigestUtils.sha1Hex from Apache Commons Codec) to compute the hash.
Access Token Management
The access token is required for most API calls. Since it expires after 2 hours, we cache it in Redis.
Define a service that retrieves the token from Redis or fetches a new one from WeChat:
@Service
public class AccessTokenService {
private static final String TOKEN_URL =
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
private static final String CACHE_KEY_PREFIX = "wechat:token:";
@Value("${wechat.appid}")
private String appId;
@Value("${wechat.secret}")
private String secret;
@Value("${wechat.refreshExpire:120}")
private long cacheExpireMinutes;
private final RedisService redisService;
public String obtainAccessToken() {
String cacheKey = CACHE_KEY_PREFIX + appId;
String token = redisService.get(cacheKey);
if (token != null) {
return token;
}
token = fetchFreshToken();
redisService.set(cacheKey, token, cacheExpireMinutes * 60);
return token;
}
private String fetchFreshToken() {
String url = String.format(TOKEN_URL, appId, secret);
String response = HttpClientUtil.get(url);
JSONObject json = JSON.parseObject(response);
String token = json.getString("access_token");
if (StringUtils.isBlank(token)) {
throw new RuntimeException("Failed to obtain access token");
}
return token;
}
}
The RedisService is a simple abstraction for Redis operations. You can replace it with RedisTemplate<String, String>.
Material Management
Uploading Permanent Images (URL Only)
Use the following endpoint to upload an image and receive only the URL:
public String uploadImageAndGetUrl(String localFilePath) {
String ext = FilenameUtils.getExtension(localFilePath).toUpperCase();
if (!Arrays.asList("JPG","PNG","JPEG","GIF","BMP").contains(ext)) {
throw new IllegalArgumentException("Unsupported image format");
}
String uploadUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=%s",
accessTokenService.obtainAccessToken());
String response = HttpClientUtil.uploadFile(uploadUrl, localFilePath, null);
JSONObject json = JSON.parseObject(response);
return json.getString("url");
}
Adding Permanent Material
For general material (image, voice, video, thumb), use the add_material API. The response includes media_id and, for images, a url.
public JSONObject addPermanentMaterial(String filePath, String type,
String videoTitle, String videoIntro) {
String addUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=%s&type=%s",
accessTokenService.obtainAccessToken(), type);
JSONObject extraFields = null;
if ("video".equals(type)) {
extraFields = new JSONObject();
extraFields.put("title", videoTitle);
extraFields.put("introduction", videoIntro);
}
String response = HttpClientUtil.uploadFile(addUrl, filePath, extraFields);
return JSON.parseObject(response);
}
Deleting Material
public void deleteMaterial(String mediaId) {
String deleteUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/material/del_material?access_token=%s",
accessTokenService.obtainAccessToken());
JSONObject body = new JSONObject();
body.put("media_id", mediaId);
String response = HttpClientUtil.postJson(deleteUrl, body);
JSONObject result = JSON.parseObject(response);
if (result.getInteger("errcode") != 0) {
throw new RuntimeException("Material deletion failed");
}
}
Draft and Rich‑Text Content
To create a draft or a news article, you need an HTML body with images uploaded to WeChat's servers.
Creating a Draft
public String createDraft(ArticleDraft draft) {
String draftUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/draft/add?access_token=%s",
accessTokenService.obtainAccessToken());
JSONObject payload = new JSONObject();
JSONArray articles = new JSONArray();
JSONObject art = (JSONObject) JSON.toJSON(draft);
articles.add(art);
payload.put("articles", articles);
String response = HttpClientUtil.postJson(draftUrl, payload);
JSONObject json = JSON.parseObject(response);
return json.getString("media_id");
}
Creating a Permanent News Article
public String createNewsArticle(ArticleDraft article) {
String newsUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/material/add_news?access_token=%s",
accessTokenService.obtainAccessToken());
JSONObject payload = new JSONObject();
JSONArray articles = new JSONArray();
articles.add(JSON.toJSON(article));
payload.put("articles", articles);
String response = HttpClientUtil.postJson(newsUrl, payload);
return JSON.parseObject(response).getString("media_id");
}
Querying and Deleting Drafts
public JSONObject fetchDraft(String mediaId) {
String getUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/draft/get?access_token=%s",
accessTokenService.obtainAccessToken());
JSONObject body = new JSONObject();
body.put("media_id", mediaId);
String response = HttpClientUtil.postJson(getUrl, body);
return JSON.parseObject(response);
}
public void removeDraft(String mediaId) {
String deleteUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/draft/delete?access_token=%s",
accessTokenService.obtainAccessToken());
JSONObject body = new JSONObject();
body.put("media_id", mediaId);
String response = HttpClientUtil.postJson(deleteUrl, body);
if (JSON.parseObject(response).getInteger("errcode") != 0) {
throw new RuntimeException("Draft deletion failed");
}
}
Publishing and Sending Messages
Submit a Draft for Free Publish
public String publishDraft(String mediaId) {
String publishUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=%s",
accessTokenService.obtainAccessToken());
JSONObject body = new JSONObject();
body.put("media_id", mediaId);
String response = HttpClientUtil.postJson(publishUrl, body);
JSONObject json = JSON.parseObject(response);
if (json.getInteger("errcode") != 0) {
throw new RuntimeException("Publish submission failed");
}
return json.getString("publish_id");
}
Query Publish Status
public JSONObject queryPublishStatus(String publishId) {
String statusUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/freepublish/get?access_token=%s",
accessTokenService.obtainAccessToken());
JSONObject body = new JSONObject();
body.put("publish_id", publishId);
String response = HttpClientUtil.postJson(statusUrl, body);
return JSON.parseObject(response);
}
Delete a Published Article
public void deletePublishedArticle(String articleId) {
String deleteUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/freepublish/delete?access_token=%s",
accessTokenService.obtainAccessToken());
JSONObject body = new JSONObject();
body.put("article_id", articleId);
body.put("index", 1);
String response = HttpClientUtil.postJson(deleteUrl, body);
if (JSON.parseObject(response).getInteger("errcode") != 0) {
throw new RuntimeException("Article deletion failed");
}
}
Mass‑Send to All Followers
public void sendToAllFollowers(String mediaId) {
String sendUrl = String.format(
"https://api.weixin.qq.com/cgi-bin/message/mass/sendall?access_token=%s",
accessTokenService.obtainAccessToken());
JSONObject payload = new JSONObject();
JSONObject filter = new JSONObject();
filter.put("is_to_all", true);
payload.put("filter", filter);
JSONObject mpnews = new JSONObject();
mpnews.put("media_id", mediaId);
payload.put("mpnews", mpnews);
payload.put("msgtype", "mpnews");
payload.put("send_ignore_reprint", 1);
String response = HttpClientUtil.postJson(sendUrl, payload);
if (JSON.parseObject(response).getInteger("errcode") != 0) {
throw new RuntimeException("Mass send failed");
}
}
HTTP Client Utilities
The HttpClientUtil class must handle both JSON payloads and multipart file uploads. Below is a simplified version using HttpURLConnection:
public class HttpClientUtil {
public static String get(String urlString) {
// standard GET implementation returning response body
}
public static String postJson(String urlString, JSONObject json) {
// POST with Content-Type: application/json
}
public static String uploadFile(String urlString, String filePath, JSONObject videoMeta) {
// multipart/form-data upload as shown in the original HttpUtils code
// The boundary and format must match WeChat's requirements exactly.
// Pay special attention to line breaks (\r\n) and the final boundary.
}
}
Data Models
@Data
public class ArticleDraft {
private String title;
private String author;
private String digest;
private String content; // HTML body, images must use WeChat URLs
private String content_source_url;
private String thumb_media_id;
private int need_open_comment;
private int only_fans_can_comment;
// optional crop coordinates for draft (pic_crop_235_1, pic_crop_1_1)
}
Important Workflow Tips
- Image handling: Before creating a draft, scan the HTML content for external image URLs, upload each to WeChat (using the
uploadimgendpoint), and replace them with the returned WeChat URLs. - Cover image: The
thumb_media_idmust come from a permanent material (useadd_materialwithtype=image). - Video material: When uploading a video, always pass
titleandintroductionas the second form field in the multipart request. - Character limits: Title ≤ 64 charactres, author ≤ 8 characters, digest ≤ 120 characters, content ≤ 20,000 characters.
- Unsupported image formats: Even if you rename a
.webpfile to.jpg, WeChat will reject it. Make sure the actual image format matches the extension. - Multipart formatting: The upload body must include precise
\r\nnewlines and a trailing boundary with--ending; otherwise the API returns a generic error.
By following these guidelines you can fully automate content creation and distribution through the WeChat Official Account platform.