Skip to content

whatsapp callback verify

GiuFLim edited this page Apr 21, 2026 · 4 revisions

whatsapp 验签说明(含完整代码与测试)

1. 适用范围

本文档说明 whatsapp 回执 的验签规则、完整实现代码和完整测试方法。

2. 方法签名

public boolean verifySign(Map<String, String> headers, String body, String accessSecret)

参数说明:

  • headers:请求头键值(至少包含 accesskeyactionbiztypetsalgorithmsign
  • body:回调请求体字符串(建议使用接收到的原始 JSON 字符串)
  • accessSecretaccessKey 对应的密钥

返回值:

  • true:验签通过
  • false:验签失败

3. 验签规则

3.1 必填检查

任一不满足直接返回 false

  • headers 不能为空
  • accessSecret 不能为空
  • 请求头字段 accesskey/action/biztype/ts/algorithm/sign 不能缺失或为空

3.2 算法选择

  • algorithm=sha256(忽略大小写)时,使用 SHA-256
  • 其他值默认使用 MD5

3.3 原始签名串拼接顺序(固定)

accessKey={accessKey}&action={action}&bizType={bizType}&ts={ts}[&body={body}]&accessSecret={accessSecret}

说明:

  • body 仅在非空时参与拼接
  • 拼接顺序必须严格一致

3.4 签名比对

  • 计算期望签名 expectSign
  • 与请求头中的 sign 忽略大小写比较

4. 完整实现代码

import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;

import java.util.Map;

public class VerifySignUtil {

    /**
     * 回执验签:
     * 1. 需要请求头包含 accessKey/action/bizType/ts/algorithm/sign
     * 2. algorithm=sha256 使用 sha256,其它值默认 md5
     * 3. body 需要和对端生成签名时保持一致(为空时不参与拼接)
     *
     * @param headers      回调请求头
     * @param body         回调body字符串(建议原始字符串)
     * @param accessSecret accessKey 对应密钥
     * @return true=验签通过, false=验签失败
     */
    public boolean verifySign(Map<String, String> headers, String body, String accessSecret) {
        if (CollectionUtils.isEmpty(headers) || StringUtils.isBlank(accessSecret)) {
            return false;
        }
        if (StringUtils.isAnyBlank(headers.get("accesskey"), headers.get("action"), headers.get("biztype"),
                headers.get("ts"), headers.get("algorithm"), headers.get("sign"))) {
            return false;
        }

        boolean isSha256 = StringUtils.equalsIgnoreCase("sha256", headers.get("algorithm"));
        String expectSign = calculateSign(headers, body, accessSecret, isSha256);
        return StringUtils.equalsIgnoreCase(expectSign, headers.get("sign"));
    }


    private String calculateSign(Map<String, String> headers, String body, String accessSecret, Boolean isSha256) {
        // 获取原始签名字符
        String row = getSignRow(headers, body, accessSecret);
        // MD5 或者 SHA256 算法加密,结果转换成十六进制小写
        return BooleanUtils.isTrue(isSha256) ? DigestUtils.sha256Hex(row) : DigestUtils.md5Hex(row);
    }


    private String getSignRow(Map<String, String> headers, String body, String accessSecret) {
        StringBuilder raw = new StringBuilder();

        // step1: 拼接 header 参数
        raw.append("accessKey=").append(headers.get("accesskey"))
                .append("&action=").append(headers.get("action"))
                .append("&bizType=").append(headers.get("biztype"))
                .append("&ts=").append(headers.get("ts"));

        // step2: 拼接 body 参数
        if (StringUtils.isNotEmpty(body)) {
            raw.append("&body=").append(body);
        }

        // step3: 拼接 accessSecret
        raw.append("&accessSecret=").append(accessSecret);

        return raw.toString();
    }
}

5. 完整测试代码(JUnit5)

import org.apache.commons.codec.digest.DigestUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.util.HashMap;
import java.util.Map;

class VerifySignUtilTest {

    private final VerifySignUtil verifySignUtil = new VerifySignUtil();

    @Test
    void verifySign_md5_shouldPass() {
        String accessSecret = "test-secret";
        String body = "{\"message\":\"ok\"}";
        Map<String, String> headers = buildHeaders("md5");

        String row = "accessKey=" + headers.get("accesskey")
                + "&action=" + headers.get("action")
                + "&bizType=" + headers.get("biztype")
                + "&ts=" + headers.get("ts")
                + "&body=" + body
                + "&accessSecret=" + accessSecret;
        headers.put("sign", DigestUtils.md5Hex(row));

        Assertions.assertTrue(verifySignUtil.verifySign(headers, body, accessSecret));
    }

    @Test
    void verifySign_sha256_shouldPass() {
        String accessSecret = "test-secret";
        String body = "{\"status\":\"delivered\"}";
        Map<String, String> headers = buildHeaders("sha256");

        String row = "accessKey=" + headers.get("accesskey")
                + "&action=" + headers.get("action")
                + "&bizType=" + headers.get("biztype")
                + "&ts=" + headers.get("ts")
                + "&body=" + body
                + "&accessSecret=" + accessSecret;
        headers.put("sign", DigestUtils.sha256Hex(row));

        Assertions.assertTrue(verifySignUtil.verifySign(headers, body, accessSecret));
    }

    @Test
    void verifySign_wrongSign_shouldFail() {
        String accessSecret = "test-secret";
        String body = "{\"message\":\"ok\"}";
        Map<String, String> headers = buildHeaders("md5");
        headers.put("sign", "wrong-sign");

        Assertions.assertFalse(verifySignUtil.verifySign(headers, body, accessSecret));
    }

    @Test
    void verifySign_missingRequiredHeader_shouldFail() {
        String accessSecret = "test-secret";
        String body = "{\"message\":\"ok\"}";
        Map<String, String> headers = buildHeaders("md5");
        headers.remove("accessKey");
        headers.put("sign", "xxx");

        Assertions.assertFalse(verifySignUtil.verifySign(headers, body, accessSecret));
    }

    private Map<String, String> buildHeaders(String algorithm) {
        Map<String, String> headers = new HashMap<>(8);
        headers.put("accesskey", "ak-demo");
        headers.put("action", "dr");
        headers.put("biztype", "2");
        headers.put("ts", "1710000000000");
        headers.put("algorithm", algorithm);
        return headers;
    }
}

简介

短信

语音

云呼叫中心(NXLink)

云呼叫中心(AI自动外呼)

Flash Call

短链

邮件验证码

DID号码

通用

号码检测

WhatsApp

Viber

Zalo ZNS

Super Message API

隐私号(旧)

PNS

坐席(旧版)

NXLINK(HKG)

NXLINK(IDN)

NXLINK(CHL)

AI Agent

RCS

Clone this wiki locally