Verify callback requests
Callback addresses must be publicly reachable, so anyone can send them forged requests. Every request the IM service sends (event callbacks, sync callbacks, URL verification and test sends) is signed, and your receiver must verify the signature and check the timestamp before processing; otherwise the request may be forged, or captured and replayed.
With Go, Python or Node.js, the server SDKs already implement the verification, replay protection and deduplication below; see "Receive callbacks" for Go, Python and Node.js.
Always compute the signature over the raw request body
The signature covers the complete raw body. Compute it from the raw bytes of the HTTP request, not by parsing the JSON into an object and serializing it again: field order, whitespace, and the encoding of Chinese characters and characters like < and > can all change, and the signature won't match.
Signing secret
Each callback address has a signing secret of the form cbs_ plus 43 characters, 47 in total, generated by the system; you can't set it yourself:
- When you create an address or rotate the secret, the console shows the full secret only once; save it right away in your receiver's config or secret manager. Afterwards the console only shows the first 8 characters (such as
cbs_oSi8) to identify it. - Secrets don't expire automatically; rotate when you need a new one, see Rotating secrets.
- Each address has a different secret. When several addresses share a receiver, pick the secret by the
X-IM-Endpoint-Idheader (orendpoint_idin the body).
Signature algorithm
签名串 = "v1:" + X-IM-Timestamp + ":" + X-IM-Nonce + ":" + 原始请求体
签名 = 小写十六进制( HMAC-SHA256( 密钥, 签名串 ) )
X-IM-Signature: v1=<签名>- Key: the ASCII bytes of the full secret string (including the
cbs_prefix), without Base64 decoding. - Signed string:
v1:, the raw value of theX-IM-Timestampheader,:, the raw value of theX-IM-Nonceheader,:, followed directly by the raw request body bytes. - Signature: the HMAC-SHA256 result encoded as 64 lowercase hex characters, written in the header as
v1=plus the signature. During a secret rotation grace period, requests are signed with both the new and old secrets, so the header has two signatures separated by a comma, new first, such asv1=3be2...9c07,v1=787b...6b9a.
Verify in these steps, returning 401 without processing if any fails:
- Read the raw request body.
- Check that
X-IM-Timestampis within 5 minutes of the local time (keep your server clock synchronized). - Compute the signature with your saved secret as above and compare it with each signature after
v1=inX-IM-Signature; any match passes. Use a constant-time comparison (such as Python'shmac.compare_digest, Go'shmac.Equalor Node.js'scrypto.timingSafeEqual) so signatures can't be guessed from response timing. - Remember
X-IM-Noncevalues seen in the last 10 minutes and reject repeats, so requests can't be replayed within the 5 minutes. - For event callbacks, also deduplicate by event
id: a retry is a new request with a different timestamp, nonce and signature, but the same event ID.
Every send (including retries, redeliveries, URL verification and test sends) is signed fresh with the current time and a new nonce, so events redelivered days later still pass the timestamp check. Signing uses the current secret at send time: after rotating, already queued events are signed with the new secret too.
Worked example
Here's a real URL verification request, signed with a secret that has since been voided, for checking your implementation:
| Item | Value |
|---|---|
| Secret | cbs_oSi8UFoUx4RQ8TlhuDzI06dQGntx4oJ6dOaloTrcCCE |
X-IM-Timestamp | 1791141598 |
X-IM-Nonce | c90a695d9294c5f757dd88350e0e3187 |
| Request body (213 bytes) | {"kind":"verify","request_id":"100310966629040128","app":"6195295143#doc-callback","endpoint_id":"100310966557736960","sent_at":"2026-10-04T19:19:58.132Z","challenge":"J9Kk_hKp_H74ZScznGSzU_-kL8b4r1MTACmzTFsNVP4"} |
X-IM-Signature | v1=2e5cebe9c6c67af85d62c4cc0250579223acc3e89549eabf036d3c523cb08610 |
Computing it with OpenSSL (with the body saved in body.json):
printf 'v1:1791141598:c90a695d9294c5f757dd88350e0e3187:%s' "$(cat body.json)" \
| openssl dgst -sha256 -hmac 'cbs_oSi8UFoUx4RQ8TlhuDzI06dQGntx4oJ6dOaloTrcCCE'SHA2-256(stdin)= 2e5cebe9c6c67af85d62c4cc0250579223acc3e89549eabf036d3c523cb08610Sample code
The three receivers below all implement full verification (signature, timestamp, nonce) and answer URL verification, sync callbacks and event callbacks. They run as is; then adapt the handling parts such as handle_hook and handle_event. All were verified against real requests from a test server: URL verification passed, test sends and sync callback tests succeeded, and with a wrong secret they returned 401 and the console showed verification failed.
The samples remember nonces in memory, which only suits a single instance. With several instances, keep nonces in shared storage such as Redis, processing only when SET im:nonce:<nonce> 1 NX EX 600 succeeds; deduplicate event IDs in shared storage or a database unique key too.
The samples listen on /im/callback, so set the callback address to https://your-server.example.com/im/callback and have your HTTPS gateway or load balancer forward to this port.
Python
Python 3.8 or later, with Flask:
# 回调接收端示例:Python 3.8+,Flask。
# 运行:pip install flask && CALLBACK_SECRET=cbs_... python callback_server.py
import hashlib
import hmac
import os
import threading
import time
from flask import Flask, abort, jsonify, request
SECRET = os.environ["CALLBACK_SECRET"] # 回调地址的签名密钥,cbs_ 开头
TOLERANCE = 300 # 时间戳与本机时间最多相差 5 分钟
NONCE_TTL = 600 # 随机数记住 10 分钟
app = Flask(__name__)
_nonces = {}
_lock = threading.Lock()
def verify_signature(secret, headers, body, now=None):
"""校验时间戳和签名。headers 不区分大小写,body 是原始请求体(bytes)。"""
now = time.time() if now is None else now
timestamp = headers.get("X-IM-Timestamp", "")
nonce = headers.get("X-IM-Nonce", "")
if not timestamp.isdigit() or abs(now - int(timestamp)) > TOLERANCE:
return False
message = b"v1:" + timestamp.encode() + b":" + nonce.encode() + b":" + body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
for part in headers.get("X-IM-Signature", "").split(","):
part = part.strip()
if part.startswith("v1=") and hmac.compare_digest(part[3:], expected):
return True
return False
def first_seen(nonce, now=None):
"""随机数第一次出现时返回 True。部署多个实例时改用 Redis:SET im:nonce:<nonce> 1 NX EX 600。"""
now = time.time() if now is None else now
with _lock:
for key in [key for key, at in _nonces.items() if now - at > NONCE_TTL]:
del _nonces[key]
if nonce in _nonces:
return False
_nonces[nonce] = now
return True
@app.post("/im/callback")
def callback():
body = request.get_data() # 用原始字节校验,不要先解析再重新序列化
if not verify_signature(SECRET, request.headers, body):
abort(401)
if not first_seen(request.headers.get("X-IM-Nonce", "")):
abort(409)
payload = request.get_json()
if payload["kind"] == "verify":
return jsonify(challenge=payload["challenge"])
if payload["kind"] == "hook":
return jsonify(handle_hook(payload["hook"], payload["data"]))
for event in payload["events"]:
handle_event(event)
return "", 204
def handle_hook(hook, data):
# 按业务判断,例如 {"action": "reject", "reason": "not_same_org", "message": "只能给同事发消息"}
return {"action": "allow"}
def handle_event(event):
# 先按 event["id"] 去重;耗时的处理放进自己的队列异步执行,尽快返回 2xx
print(event["id"], event["type"], event["data"])
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))With other frameworks, also take the raw body: request.body in Django, await request.body() in FastAPI.
Go
Go 1.22 or later, standard library only (the go version in go.mod at least 1.22):
// 回调接收端示例:Go 1.22+,只用标准库。
// 运行:CALLBACK_SECRET=cbs_... go run main.go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
const (
tolerance = 5 * time.Minute // 时间戳与本机时间最多相差 5 分钟
nonceTTL = 10 * time.Minute // 随机数记住 10 分钟
)
// verifySignature 校验时间戳和签名,body 是原始请求体。
func verifySignature(secret string, header http.Header, body []byte, now time.Time) bool {
timestamp := header.Get("X-IM-Timestamp")
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if diff := now.Sub(time.Unix(seconds, 0)); diff > tolerance || diff < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte("v1:" + timestamp + ":" + header.Get("X-IM-Nonce") + ":"))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
for _, part := range strings.Split(header.Get("X-IM-Signature"), ",") {
signature, ok := strings.CutPrefix(strings.TrimSpace(part), "v1=")
if ok && hmac.Equal([]byte(signature), []byte(expected)) {
return true
}
}
return false
}
// nonceStore 记住最近 10 分钟的随机数。部署多个实例时改用 Redis:SET im:nonce:<nonce> 1 NX EX 600。
type nonceStore struct {
mu sync.Mutex
seen map[string]time.Time
}
// firstSeen 在随机数第一次出现时返回 true。
func (s *nonceStore) firstSeen(nonce string, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, at := range s.seen {
if now.Sub(at) > nonceTTL {
delete(s.seen, key)
}
}
if _, ok := s.seen[nonce]; ok {
return false
}
s.seen[nonce] = now
return true
}
type callbackRequest struct {
Kind string `json:"kind"`
Hook string `json:"hook"`
Challenge string `json:"challenge"`
Data json.RawMessage `json:"data"`
Events []struct {
ID string `json:"id"`
Type string `json:"type"`
OccurredAt string `json:"occurred_at"`
Attempt int `json:"attempt"`
Test bool `json:"test"`
Data json.RawMessage `json:"data"`
} `json:"events"`
}
func main() {
secret := os.Getenv("CALLBACK_SECRET") // 回调地址的签名密钥,cbs_ 开头
nonces := &nonceStore{seen: map[string]time.Time{}}
http.HandleFunc("POST /im/callback", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
now := time.Now()
if !verifySignature(secret, r.Header, body, now) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
if !nonces.firstSeen(r.Header.Get("X-IM-Nonce"), now) {
http.Error(w, "replayed", http.StatusConflict)
return
}
var req callbackRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
switch req.Kind {
case "verify":
json.NewEncoder(w).Encode(map[string]string{"challenge": req.Challenge})
case "hook":
// 按 req.Hook 和 req.Data 判断,例如 {"action": "reject", "reason": "not_same_org", "message": "只能给同事发消息"}
json.NewEncoder(w).Encode(map[string]string{"action": "allow"})
default:
for _, event := range req.Events {
// 先按 event.ID 去重;耗时的处理放进自己的队列异步执行,尽快返回 2xx
log.Println(event.ID, event.Type, string(event.Data))
}
w.WriteHeader(http.StatusNoContent)
}
})
addr := ":8080"
if port := os.Getenv("PORT"); port != "" {
addr = ":" + port
}
log.Fatal(http.ListenAndServe(addr, nil))
}Node.js
Node.js 18 or later, built-in modules only:
// 回调接收端示例:Node.js 18+,只用内置模块。
// 运行:CALLBACK_SECRET=cbs_... node server.js
const crypto = require('node:crypto');
const http = require('node:http');
const SECRET = process.env.CALLBACK_SECRET; // 回调地址的签名密钥,cbs_ 开头
const TOLERANCE = 300; // 时间戳与本机时间最多相差 5 分钟
const NONCE_TTL = 600 * 1000; // 随机数记住 10 分钟
const nonces = new Map();
// 校验时间戳和签名。headers 的键为小写(Node.js 的 req.headers),body 是原始请求体(Buffer)。
function verifySignature(secret, headers, body, now = Date.now() / 1000) {
const timestamp = headers['x-im-timestamp'] || '';
const nonce = headers['x-im-nonce'] || '';
if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > TOLERANCE) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`v1:${timestamp}:${nonce}:`).update(body).digest('hex'),
);
return (headers['x-im-signature'] || '').split(',').some((part) => {
part = part.trim();
if (!part.startsWith('v1=')) return false;
const signature = Buffer.from(part.slice(3));
return signature.length === expected.length && crypto.timingSafeEqual(signature, expected);
});
}
// 随机数第一次出现时返回 true。部署多个实例时改用 Redis:SET im:nonce:<nonce> 1 NX EX 600。
function firstSeen(nonce, now = Date.now()) {
for (const [key, at] of nonces) if (now - at > NONCE_TTL) nonces.delete(key);
if (nonces.has(nonce)) return false;
nonces.set(nonce, now);
return true;
}
function reply(res, status, value) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(value === undefined ? '' : JSON.stringify(value));
}
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url.split('?')[0] !== '/im/callback') return reply(res, 404);
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks); // 用原始字节校验,不要先解析再重新序列化
if (!verifySignature(SECRET, req.headers, body)) return reply(res, 401, { error: 'invalid signature' });
if (!firstSeen(req.headers['x-im-nonce'])) return reply(res, 409, { error: 'replayed' });
const payload = JSON.parse(body);
if (payload.kind === 'verify') return reply(res, 200, { challenge: payload.challenge });
// 按 payload.hook 和 payload.data 判断,例如 { action: 'reject', reason: 'not_same_org', message: '只能给同事发消息' }
if (payload.kind === 'hook') return reply(res, 200, { action: 'allow' });
for (const event of payload.events) {
// 先按 event.id 去重;耗时的处理放进自己的队列异步执行,尽快返回 2xx
console.log(event.id, event.type, JSON.stringify(event.data));
}
reply(res, 204);
});
});
if (require.main === module) server.listen(Number(process.env.PORT) || 8080);
module.exports = { verifySignature };With Express, don't let express.json() parse the body first; use express.raw({ type: 'application/json' }) to get a Buffer and JSON.parse after verifying, or save the raw bytes in express.json({ verify: (req, res, buf) => { req.rawBody = buf } }).
Debugging signatures
- Get test sends working first: click "Test" on the address in the console and send one event type; the result shows the full request body sent, the status code returned and the start of the response. Test sends don't require the address to have passed URL verification, so you can get signatures right before verifying.
- Compare with the worked example: check your implementation against the worked example above, and look for other causes once it matches.
Common reasons verification keeps failing:
| Cause | Fix |
|---|---|
| The framework already parsed the JSON, and you computed over the re-serialized result | Read the raw body instead, see each language above |
| The secret was copied with extra spaces or line breaks, or is another address's secret | Check the secret again; each address has its own |
| After rotation, the grace period ended and the receiver still uses the old secret | Switch to the new secret; if you didn't save it, rotate again |
| The server clock is off and the timestamp check fails | Turn on NTP time sync |
Only the first signature in X-IM-Signature is compared | There are two during the grace period; compare each, and any match passes |
| A gateway or proxy changed the body (re-encoding, compression and so on) | Have the gateway forward the body as is |
Rotating secrets
If you suspect a secret leaked, or your security policy requires regular changes, an owner or admin can rotate the secret in the address details in the console (re-verifying their identity), see Callback configuration:
- Click "Rotate", enter the old secret's grace period (1 to 168 hours, 24 by default), and save the new secret. The new secret takes effect immediately.
- During the grace period every request carries both signatures, so receivers verify with either secret and you can switch their config to the new one at your own pace.
- Once all receivers use the new secret, click "End grace period early" to sign with the new secret only; otherwise the old secret is voided when the grace period ends.
You can't rotate again before the grace period ends (409 slot_unavailable); end it first. If you suspect a leak, rotate with a 1-hour grace period and end it as soon as the receivers have the new secret.
Other security advice
- HTTPS only: callback addresses must be HTTPS, so give your receiver a valid certificate. Responses aren't signed; HTTPS guarantees their authenticity.
- Restrict source IPs: when the console's "Callbacks" page lists egress IPs, allow only those in your firewall as an extra layer beyond signatures.
- Trust the body:
X-IM-Kind,X-IM-Request-IdandX-IM-Endpoint-Idin the headers are just copies of the same fields in the body, for routing only; base decisions on the signature-verified body. - Tokens in the URL: you can add the receiver's own token as a query parameter (such as
?token=...), which the console masks. It's only an additional check and can't replace signatures. - Protect the secret: keep it only on the receiver, never in code repositories or logs.
