SPI签名规则
1.前置条件:SPI配置接入
前置条件:进行SPI回调配置,配置路径:登录抖音开放平台,控制台-生活服务商家应用-开发配置-SPI回调
注意:回调地址必须是 https 回调地址签名机制
更多接入配置详情参考文档:SPI
注:签名规则,服务商需根据【签名规则方式选择】选用适用自身情况的某种进行校验,新接入的一定选择新版签名规则(安全性更高)。两个签名的计算方式存在细微差别,具体见【签名计算方法】。
2.签名规则client_key说明
服务商原有应用通过抖音开放平台申请的网站应用,迁移至服务商后台后,新本地生活应用的 app_id(client_key)将与原 client_key 不同。这一点在调用 spi 的场景里体现在 url 中的 client_key 和新的 client_key 中:
服务商未做过迁移:
url 中的 client_key 和 header 中的 x-life-clientkey 相同。
如果服务商不清楚迁移为什么动作,则也适用该规则。
服务商做过迁移 :
url 中的 client_key 和 header 中的 x-life-clientkey 不同,header 中的 client_key 为迁移后的服务商平台上生活服务应用的 ck。
3.签名规则方式选择说明
- •服务商一开始使用的平台为服务商后台:两种签名都可以校验,从安全性的角度考虑,建议使用新的签名规则(header 中的签名)进行校验。
- •服务商一开始使用的企业号开放接口(飞书文档),且从未使用过新的服务商平台(当前在线平台),仅支持校验旧版签名(URL 中的签名)。
- •服务商一开始使用的企业号开放接口(飞书文档),且根据指引迁移到了新的服务商平台(当前在线平台),代码中使用的应用的 client_key 换成了新的本地生活应用的 app_id(client_key),仅支持校验旧版签名(Header 中的签名)。
4.签名算法原理
新版签名规则
抖音开放平台提供了Go、Java语言的签名算法代码,如果您使用上述语言开发,建议直接复制相关代码即可。若您使用其他语言,请参考签名算法原理自行实现。
URL 中的参数
参数名称 | 参数类型 | 参数描述 | 必需 |
client_key | string | 服务商的client_key | 是 |
timestamp | string | 时间戳, 单位毫秒 | 是 |
sign | string | 生成签名时忽略该字段 | 否 |
Header 中的 sign 参数
调用路径为抖音调用服务商的接口,会传递以下内容在 Header 中
参数名称 | 参数类型 | 参数描述 | 必需 |
x-life-clientkey | string | 服务商的client_key | 是(仅作应用标识用) |
x-life-sign | string | 新签名 | 是 验签用 |
sign 的生成步骤
- 1.以三方服务商的 client_secret 开头; 然后将除 sign 之外的 URL 参数以 key=value 的 形式按 key 的字典升序排列; 若为 POST 请求, 还需要加上 HTTP 的 BODY, key 固定为 http_body(http_body 的内容不参与排序,加到最后)。最后所有的这些项以字符'&'串联起来即为待签名内容 str1。
- 2.str1 的内容是 yyyyyy&client_key=xxxxxx×tamp=1624293280123&http_body=zzzzzz 【注意:client_key 参数需从 URL 中动态获取,请勿直接固定写死,否则会导致验签失败】
- 3.然后将待签名内容 str1 计算 SHA-256 哈希值,得到的结果即为 sign。
旧版签名规则
若您使用旧版签名规则,请参考签名算法原理自行实现。
说明
1、旧签名规则是指之前从抖音开放平台(open.douyin.com)接入网页应用及接口所使用的签名规则,迁移至服务商平台(partner.open-douyin.com)后,需要改成上方新的签名规则。
2、如不涉及迁移,可忽略该部分。
3、如果服务商是新入驻/新接入的服务商,不需要关注该签名方法。
URL 中的 sign 参数(之前的签名方式)
调用路径为抖音调用服务商的接口,会传递签名&公共 URL 参数
参数名称 | 参数类型 | 参数描述 | 必需 |
client_key | string | 服务商的client_key | 是 |
timestamp | string | 时间戳,单位为毫秒 | 是 |
sign | string | 签名 | 是 |
sign 的生成步骤
- 1.以三方服务商的 client_secret 开头; 然后将除 sign 之外的 URL 参数以 key=value 的形式按 key 的字典升序排列; 若为 POST 请求, 还需要加上 HTTP 的 BODY, key 固定为 http_body(http_body 的内容不参与排序,加到最后)。最后所有的这些项以字符'&'串联起来即为待签名内容 str1。
- 2.str1 的内容是 yyyyyy&client_key=xxxxxx×tamp=1624293280123&http_body=zzzzzz 【注意:client_key 参数需从 URL 中动态获取,请勿直接固定写死,否则会导致验签失败】
- 3.然后将待签名内容 str1 计算 MD5 值, 得到的结果即为 sign。
常见语言签名算法代码
Go
package main import ( "bytes" "fmt""net/HTTP" "douyin-sign-verify-go/pkg/verify" ) // 该示例演示如何在无网络环境下构造一个请求并进行新旧签名的验签 func main() { clientSecret := "yyyyyy" url := "https://svc.example/spi?client_key=xxxxxx×tamp=1624293280123" body := []byte("zzzzzz") req, err := HTTP.NewRequest(HTTP.MethodPost, url, bytes.NewReader(body)) if err != nil { panic(err) } // 计算签名,并设置到请求中(模拟抖音侧的行为) q := req.URL.Query() newSig := verify.ComputeNewSignature(clientSecret, q, body) oldSig := verify.ComputeOldSignature(clientSecret, q, body) req.Header.Set("x-life-sign", newSig) q.Set("sign", oldSig) req.URL.RawQuery = q.Encode() // 服务商侧验签 okNew, expectedNew, providedNew := verify.VerifyNewSignatureFromRequest(req, clientSecret) okOld, expectedOld, providedOld := verify.VerifyOldSignatureFromRequest(req, clientSecret) fmt.Printf("Verify new (header x-life-sign): ok=%v expected=%s provided=%s\n", okNew, expectedNew, providedNew) fmt.Printf("Verify old (url sign): ok=%v expected=%s provided=%s\n", okOld, expectedOld, providedOld) }
package verify import ( "bytes" "crypto/md5" "crypto/sha256" "encoding/hex" "io" "net/HTTP" "net/url" "sort" "strings" ) // BuildSignString 构造待签名字符串 str1。 // 规则: // 1) 以 client_secret 开头; // 2) URL 参数除 key=="sign" 外,按 key 字典升序遍历;每个 key 的多个值也按值字典升序逐个追加为 key=value; // 3) 若为 POST 请求需在末尾追加 http_body=原始字节内容(http_body 不参与排序,仅追加到最后)。 // 本函数无法感知 HTTP 方法,约定:当 body!=nil 时即视为需要追加 http_body(即便 len(body)==0)。 // 4) 各项之间用字符 '&' 连接。 func BuildSignString(clientSecret string, query url.Values, body []byte) string { var b strings.Builder b.WriteString(clientSecret) // 收集并排序所有非 sign 的键 keys := make([]string, 0, len(query)) for k := range query { if strings.EqualFold(k, "sign") { // 忽略 URL 中的 sign 参数 continue } keys = append(keys, k) } sort.Strings(keys) // 逐 key 的值按字典序追加 for _, k := range keys { vals := query[k] sort.Strings(vals) if len(vals) == 0 { b.WriteString("&") b.WriteString(k) b.WriteString("=") continue } for _, v := range vals { b.WriteString("&") b.WriteString(k) b.WriteString("=") b.WriteString(v) } } // 末尾追加 http_body(不参与排序) if body != nil { b.WriteString("&http_body=") b.WriteString(string(body)) } return b.String() } // ComputeNewSignature 计算新签名:sha256(str1) 的十六进制小写 func ComputeNewSignature(clientSecret string, query url.Values, body []byte) string { str1 := BuildSignString(clientSecret, query, body) sum := sha256.Sum256([]byte(str1)) return strings.ToLower(hex.EncodeToString(sum[:])) } // ComputeOldSignature 计算旧签名:md5(str1) 的十六进制小写 func ComputeOldSignature(clientSecret string, query url.Values, body []byte) string { str1 := BuildSignString(clientSecret, query, body) sum := md5.Sum([]byte(str1)) return strings.ToLower(hex.EncodeToString(sum[:])) } // VerifyNewSignatureFromRequest 校验请求头中的新签名(x-life-sign)。 // 返回:是否通过、期望值(小写)、请求方提供值(统一转小写)。 // 注意:读取 r.Body 后会回填原始内容,以保证后续处理不受影响。 func VerifyNewSignatureFromRequest(r *HTTP.Request, clientSecret string) (ok bool, expected string, provided string) { var body []byte if r != nil && r.Body != nil && r.Method == HTTP.MethodPost { data, err := io.ReadAll(r.Body) if err == nil { body = data } else { // 读取失败时按空 body 处理,但仍回填占位 body = nil } r.Body = io.NopCloser(bytes.NewReader(body)) } q := r.URL.Query() expected = ComputeNewSignature(clientSecret, q, body) provided = strings.ToLower(strings.TrimSpace(r.Header.Get("x-life-sign"))) ok = (provided != "" && provided == expected) return } // VerifyOldSignatureFromRequest 校验 URL 参数中的旧签名(sign)。 // 返回:是否通过、期望值(小写)、请求方提供值(统一转小写)。 // 注意:读取 r.Body 后会回填原始内容,以保证后续处理不受影响。 func VerifyOldSignatureFromRequest(r *HTTP.Request, clientSecret string) (ok bool, expected string, provided string) { var body []byte if r != nil && r.Body != nil && r.Method == HTTP.MethodPost { data, err := io.ReadAll(r.Body) if err == nil { body = data } else { body = nil } r.Body = io.NopCloser(bytes.NewReader(body)) } q := r.URL.Query() expected = ComputeOldSignature(clientSecret, q, body) provided = strings.ToLower(strings.TrimSpace(q.Get("sign"))) ok = (provided != "" && provided == expected) return }
Java
import java.util.*; import java.net.*; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.io.*; /** * 抖音 SPI 验签 Java 单文件示例(与 Go 单文件风格一致)。 * 仅使用标准库实现,严格遵循官方签名规则: * - 新签名(header: x-life-sign):sha256(待签名串) * - 旧签名(url: sign):md5(待签名串) * 待签名串拼接规则: * 以 client_secret 开头;按 key 的字典升序追加 key=value(忽略 key=="sign"), * 每个 key 的多个值按字典升序逐个追加; * 当 body 非空时,末尾追加 "http_body=" + 原始字节作为字符串(UTF-8); * 各项用 '&' 连接。 */ public class DouyinSignVerifyDemo { public static class SimpleRequest { public String method; public URI uri; public Map<String, String> headers; public byte[] body; } public static class VerifyResult { public boolean ok; public String expected; public String provided; public VerifyResult(boolean ok, String expected, String provided) { this.ok = ok; this.expected = expected; this.provided = provided; } } /** * 构造待签名字符串。 * 规则: * - 以 clientSecret 开头; * - 追加按 key 字典升序的 "key=value",忽略 key=="sign"; * - 对每个 key 的多个值按字典升序逐个追加; * - 当 body 非空时,末尾追加 "http_body=" + new String(body, UTF-8); * - 各项用 '&' 连接。 */ public static String buildSignString(String clientSecret, Map<String, List<String>> query, byte[] body) { if (clientSecret == null) clientSecret = ""; List<String> keys = new ArrayList<>(query != null ? query.keySet() : Collections.<String>emptySet()); Collections.sort(keys); List<String> parts = new ArrayList<>(); parts.add(clientSecret); for (String key : keys) { if ("sign".equals(key)) { // 验签时必须忽略 URL 中的 sign 参数 continue; } List<String> values = query.get(key); if (values == null || values.isEmpty()) { continue; } List<String> sortedValues = new ArrayList<>(values); Collections.sort(sortedValues); for (String v : sortedValues) { parts.add(key + "=" + (v == null ? "" : v)); } } if (body != null && body.length > 0) { parts.add("http_body=" + new String(body, StandardCharsets.UTF_8)); } return String.join("&", parts); } /** * 计算新签名:sha256 十六进制小写。 */ public static String computeNewSignature(String clientSecret, Map<String, List<String>> query, byte[] body) { String signStr = buildSignString(clientSecret, query, body); return toHexLower(sha256Bytes(signStr)); } /** * 计算旧签名:md5 十六进制小写。 */ public static String computeOldSignature(String clientSecret, Map<String, List<String>> query, byte[] body) { String signStr = buildSignString(clientSecret, query, body); return toHexLower(md5Bytes(signStr)); } /** * 校验新签名(header: x-life-sign)。 */ public static VerifyResult verifyNewSignature(SimpleRequest r, String clientSecret) { Map<String, List<String>> query = parseQuery(r != null && r.uri != null ? r.uri.getRawQuery() : null); String expected = computeNewSignature(clientSecret, query, r != null ? r.body : null); String provided = null; if (r != null && r.headers != null) { provided = r.headers.get("x-life-sign"); } boolean ok = provided != null && expected.equals(provided.toLowerCase(Locale.ROOT)); return new VerifyResult(ok, expected, provided); } /** * 校验旧签名(url: sign)。 */ public static VerifyResult verifyOldSignature(SimpleRequest r, String clientSecret) { Map<String, List<String>> query = parseQuery(r != null && r.uri != null ? r.uri.getRawQuery() : null); String expected = computeOldSignature(clientSecret, query, r != null ? r.body : null); String provided = null; List<String> signVals = query.get("sign"); if (signVals != null && !signVals.isEmpty()) { provided = signVals.get(0); } boolean ok = provided != null && expected.equals(provided.toLowerCase(Locale.ROOT)); return new VerifyResult(ok, expected, provided); } /** * 解析原始 URL 查询串为 Map<String, List<String>>。 * 使用 URLDecoder.decode(rawQuery, "UTF-8") 再拆分。 */ public static Map<String, List<String>> parseQuery(String rawQuery) { Map<String, List<String>> result = new LinkedHashMap<>(); if (rawQuery == null || rawQuery.isEmpty()) { return result; } String decoded; try { decoded = URLDecoder.decode(rawQuery, "UTF-8"); } catch (UnsupportedEncodingException e) { // UTF-8 一定存在,兜底使用原串 decoded = rawQuery; } String[] pairs = decoded.split("&"); for (String p : pairs) { if (p == null || p.isEmpty()) continue; int idx = p.indexOf('='); String key; String val; if (idx >= 0) { key = p.substring(0, idx); val = p.substring(idx + 1); } else { key = p; val = ""; } List<String> list = result.computeIfAbsent(key, k -> new ArrayList<>()); list.add(val); } return result; } /** 将字节数组转为十六进制小写字符串。 */ public static String toHexLower(byte[] bytes) { char[] HEX = "0123456789abcdef".toCharArray(); char[] out = new char[bytes.length * 2]; int i = 0; for (byte b : bytes) { out[i++] = HEX[(b >>> 4) & 0x0F]; out[i++] = HEX[b & 0x0F]; } return new String(out); } /** 计算 sha256 原始字节。 */ public static byte[] sha256Bytes(String s) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(s.getBytes(StandardCharsets.UTF_8)); return md.digest(); } catch (Exception e) { throw new RuntimeException(e); } } /** 计算 md5 原始字节。 */ public static byte[] md5Bytes(String s) { try { MessageDigest md = MessageDigest.getInstance("MD5"); md.update(s.getBytes(StandardCharsets.UTF_8)); return md.digest(); } catch (Exception e) { throw new RuntimeException(e); } } /** 演示:构造请求,计算并设置新旧签名,执行校验。 */ public static void main(String[] args) { String clientSecret = "yyyyyy"; String clientKey = "xxxxxx"; String timestamp = "1624293280123"; String baseUrl = "HTTP://localhost:8080/spi/demo"; // 初始查询参数(不包含 sign) String rawQuery = "client_key=" + clientKey + "×tamp=" + timestamp; byte[] body = "{\"foo\":\"bar\",\"count\":1}".getBytes(StandardCharsets.UTF_8); // 计算新旧签名 Map<String, List<String>> queryForSign = parseQuery(rawQuery); String newSign = computeNewSignature(clientSecret, queryForSign, body); String oldSign = computeOldSignature(clientSecret, queryForSign, body); // 构造 headers,设置新签名 Map<String, String> headers = new LinkedHashMap<>(); headers.put("x-life-sign", newSign); // 在 URL 中追加旧签名 sign String rawQueryWithSign = rawQuery + "&sign=" + oldSign; URI uri = URI.create(baseUrl + "?" + rawQueryWithSign); // 构造 SimpleRequest SimpleRequest req = new SimpleRequest(); req.method = "POST"; req.uri = uri; req.headers = headers; req.body = body; // 注意:必须使用原始字节 // 执行校验 VerifyResult resNew = verifyNewSignature(req, clientSecret); VerifyResult resOld = verifyOldSignature(req, clientSecret); System.out.println("New signature verify: ok=" + resNew.ok); System.out.println(" expected=" + resNew.expected); System.out.println(" provided=" + resNew.provided); System.out.println("Old signature verify: ok=" + resOld.ok); System.out.println(" expected=" + resOld.expected); System.out.println(" provided=" + resOld.provided); // 预期两者均为 true if (resNew.ok && resOld.ok) { System.out.println("Both verifications passed."); } else { System.out.println("Verification failed."); } } }
NodeJS
const crypto = require('crypto'); const { URL } = require('url'); /** * 构造待签名字符串 str1 */ function buildSignString(clientSecret, query, body) { let result = clientSecret; // query 预期是一个 URLSearchParams 对象 const keys = []; for (const [k] of query.entries()) { if (k.toLowerCase() === 'sign') continue; if (!keys.includes(k)) { keys.push(k); } } keys.sort(); for (const k of keys) { const vals = query.getAll(k); vals.sort(); if (vals.length === 0) { result += `&${k}=`; continue; } for (const v of vals) { result += `&${k}=${v}`; } } if (body !== null && body !== undefined) { result += `&http_body=${body.toString()}`; } return result; } /** * 计算新签名:sha256(str1) */ function computeNewSignature(clientSecret, query, body) { const str1 = buildSignString(clientSecret, query, body); return crypto.createHash('sha256').update(str1).digest('hex').toLowerCase(); } /** * 计算旧签名:md5(str1) */ function computeOldSignature(clientSecret, query, body) { const str1 = buildSignString(clientSecret, query, body); return crypto.createHash('md5').update(str1).digest('hex').toLowerCase(); } /** * 校验新签名(x-life-sign) */ function verifyNewSignatureFromRequest(req, clientSecret) { let body = req.method === 'POST' ? req.body : null; const urlObj = new URL(req.url, 'HTTP://localhost'); const q = urlObj.searchParams; const expected = computeNewSignature(clientSecret, q, body); const provided = (req.headers['x-life-sign'] || "").trim().toLowerCase(); return { ok: provided !== "" && provided === expected, expected, provided }; } /** * 校验旧签名(sign) */ function verifyOldSignatureFromRequest(req, clientSecret) { let body = req.method === 'POST' ? req.body : null; const urlObj = new URL(req.url, 'HTTP://localhost'); const q = urlObj.searchParams; const expected = computeOldSignature(clientSecret, q, body); const provided = (q.get('sign') || "").trim().toLowerCase(); return { ok: provided !== "" && provided === expected, expected, provided }; } function runVerificationDemo() { const clientSecret = "yyyyyy"; const urlStr = "https://svc.example/spi?client_key=xxxxxx×tamp=1624293280123"; const bodyContent = Buffer.from("zzzzzz"); // 1. 解析 URL 以获取参数 const urlObj = new URL(urlStr); const q = urlObj.searchParams; // 2. 计算签名 (模拟抖音侧行为) const newSig = computeNewSignature(clientSecret, q, bodyContent); const oldSig = computeOldSignature(clientSecret, q, bodyContent); // 3. 构造模拟请求对象 (模拟服务商接收到的请求) const req = { method: 'POST', url: urlStr, headers: { 'x-life-sign': newSig }, body: bodyContent }; // 4. 在 URL 中追加旧版签名 sign urlObj.searchParams.set('sign', oldSig); req.url = urlObj.toString(); // 5. 调用验证函数进行验签 (服务商侧验签逻辑) const resNew = verifyNewSignatureFromRequest(req, clientSecret); const resOld = verifyOldSignatureFromRequest(req, clientSecret); // 6. 打印结果 (对应 Go 的 fmt.Printf) console.log(`Verify new (header x-life-sign): ok=${resNew.ok} expected=${resNew.expected} provided=${resNew.provided}`); console.log(`Verify old (url sign): ok=${resOld.ok} expected=${resOld.expected} provided=${resOld.provided}`); } // 执行验证演示 runVerificationDemo();
5.注意事项
无论新版规则、旧版规则,抖音将用于签名生成的 httpBody 以[]byte 类型请求服务商,服务商请不要将[]byte 反序列化成 object 再序列化 string 用于签名校验。这样会导致 json 中的字段顺序与抖音不符,同时若抖音侧将 httpBody 进行改动,也会导致签名校验不通过。
6.常见问题
问题 | 建议方案 |
验签验证失败或不通过 |
client_secret 开头 → 按字典升序排列拼接除 sign 之外的所有URL参数 (key=value) → 如果是POST请求,在末尾追加 &http_body= 和请求体原始内容。
client_key 等参数必须从请求的URL中动态获取,不能写死。
http_body 必须是请求体(http_body)的原始字节内容。请不要将接收到的 []byte 类型Body反序列化为对象再重新序列化成字符串,因为JSON字段顺序或空格等细微差异都会导致签名不同。 |
更换SPI 回调地址后,旧订单/商品等变更状态时是否会通过新接口推送数据? | 修改spi地址,且新spi地址能正常使用时,对于存量业务数据变更是可以接收到的 |
