Mẫu xác minh
Mỗi mẫu thực hiện cùng năm bước được mô tả trong Webhook: kiểm tra độ mới, kiểm tra chữ ký theo thời gian hằng định, nonce, phân tích, loại bỏ trùng lặp theo ID sự kiện. Kho lưu nonce được minh họa bằng Redis; bất kỳ kho nào có thao tác set-if-absent nguyên tử và TTL đều dùng được.
Python (Flask)
Phần tiêu đề “Python (Flask)”import hmac, hashlib, json, timefrom flask import Flask, request, abortimport redis
app = Flask(__name__)r = redis.Redis()SECRET = os.environ["ROLCHAT_WEBHOOK_SECRET"].encode()
@app.post("/rolchat/webhook")def webhook(): ts = request.headers.get("X-RolChat-Timestamp", "") sig = request.headers.get("X-RolChat-Signature", "") nonce = request.headers.get("X-RolChat-Nonce", "") event_id = request.headers.get("X-RolChat-Event-Id", "") body = request.get_data() # raw bytes
if not ts.isdigit() or abs(time.time() - int(ts)) > 300: abort(400, "timestamp_out_of_window")
expected = "sha256=" + hmac.new(SECRET, f"{ts}.".encode() + body, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): abort(401, "invalid_signature")
if not r.set(f"webhook_nonce:{nonce}", "1", nx=True, ex=600): abort(409, "duplicate_nonce")
event = json.loads(body) if not r.set(f"webhook_event:{event_id}", "1", nx=True, ex=7 * 24 * 3600): return "", 200 # already processed
enqueue(event) # do the work off the request thread return "", 200Node.js (Express)
Phần tiêu đề “Node.js (Express)”import crypto from 'node:crypto';import express from 'express';import Redis from 'ioredis';
const app = express();const redis = new Redis();const SECRET = process.env.ROLCHAT_WEBHOOK_SECRET;
// Keep the raw body: express.json() would re-serialise it.app.post('/rolchat/webhook', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => { const ts = req.get('X-RolChat-Timestamp') ?? ''; const sig = req.get('X-RolChat-Signature') ?? ''; const nonce = req.get('X-RolChat-Nonce') ?? ''; const eventId = req.get('X-RolChat-Event-Id') ?? '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) { return res.status(400).send('timestamp_out_of_window'); }
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET) .update(`${ts}.`).update(req.body).digest('hex'); const a = Buffer.from(sig), b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(401).send('invalid_signature'); }
if (!(await redis.set(`webhook_nonce:${nonce}`, '1', 'EX', 600, 'NX'))) { return res.status(409).send('duplicate_nonce'); }
const event = JSON.parse(req.body.toString('utf8')); if (!(await redis.set(`webhook_event:${eventId}`, '1', 'EX', 7 * 86400, 'NX'))) { return res.sendStatus(200); // already processed }
await queue.add('rolchat-event', event); res.sendStatus(200);});Go (net/http)
Phần tiêu đề “Go (net/http)”package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "math" "net/http" "strconv" "time")
var secret = []byte(os.Getenv("ROLCHAT_WEBHOOK_SECRET"))
func webhook(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) ts := r.Header.Get("X-RolChat-Timestamp") sig := r.Header.Get("X-RolChat-Signature") nonce := r.Header.Get("X-RolChat-Nonce") eventID := r.Header.Get("X-RolChat-Event-Id")
t, err := strconv.ParseInt(ts, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 { http.Error(w, "timestamp_out_of_window", http.StatusBadRequest) return }
mac := hmac.New(sha256.New, secret) mac.Write([]byte(ts + ".")) mac.Write(body) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(sig), []byte(expected)) { http.Error(w, "invalid_signature", http.StatusUnauthorized) return }
// SET NX EX 600 - any store with an atomic set-if-absent works. if !nonceStore.SetNX("webhook_nonce:"+nonce, 10*time.Minute) { http.Error(w, "duplicate_nonce", http.StatusConflict) return } if !nonceStore.SetNX("webhook_event:"+eventID, 7*24*time.Hour) { w.WriteHeader(http.StatusOK) // already processed return }
go process(body) w.WriteHeader(http.StatusOK)}Ruby (Rack / Rails)
Phần tiêu đề “Ruby (Rack / Rails)”require "openssl"require "json"
SECRET = ENV.fetch("ROLCHAT_WEBHOOK_SECRET")
def rolchat_webhook(request) body = request.raw_post # raw bytes, not params ts = request.headers["X-RolChat-Timestamp"].to_s sig = request.headers["X-RolChat-Signature"].to_s nonce = request.headers["X-RolChat-Nonce"].to_s event_id = request.headers["X-RolChat-Event-Id"].to_s
return [400, "timestamp_out_of_window"] unless ts =~ /\A\d+\z/ && (Time.now.to_i - ts.to_i).abs <= 300
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{ts}.#{body}") return [401, "invalid_signature"] unless ActiveSupport::SecurityUtils.secure_compare(sig, expected)
return [409, "duplicate_nonce"] unless REDIS.set("webhook_nonce:#{nonce}", "1", nx: true, ex: 600)
event = JSON.parse(body) return [200, ""] unless REDIS.set("webhook_event:#{event_id}", "1", nx: true, ex: 7 * 86_400)
RolchatEventJob.perform_later(event) [200, ""]end<?php$secret = getenv('ROLCHAT_WEBHOOK_SECRET');$body = file_get_contents('php://input'); // raw bytes$ts = $_SERVER['HTTP_X_ROLCHAT_TIMESTAMP'] ?? '';$sig = $_SERVER['HTTP_X_ROLCHAT_SIGNATURE'] ?? '';$nonce = $_SERVER['HTTP_X_ROLCHAT_NONCE'] ?? '';$eventId = $_SERVER['HTTP_X_ROLCHAT_EVENT_ID'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) { http_response_code(400); exit('timestamp_out_of_window');}
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);if (!hash_equals($expected, $sig)) { http_response_code(401); exit('invalid_signature');}
$redis = new Redis(); $redis->connect('127.0.0.1');if (!$redis->set("webhook_nonce:$nonce", '1', ['nx', 'ex' => 600])) { http_response_code(409); exit('duplicate_nonce');}
$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);if (!$redis->set("webhook_event:$eventId", '1', ['nx', 'ex' => 7 * 86400])) { http_response_code(200); exit; // already processed}
enqueue($event);http_response_code(200);Kiểm thử
Phần tiêu đề “Kiểm thử”Yêu cầu RolChat gửi một sự kiện có ký giả lập đến endpoint của bạn:
POST /api/v1/webhooks/test-signatureAuthorization: Bearer pat_…X-Account-Id: <account uuid>Nếu bộ nhận của bạn trả về 200 cho lệnh gọi đó và 401 khi bạn thay đổi một byte của body, nó đã hoạt động đúng.