أمثلة التحقق
ينفّذ كل مثال الأمور الخمسة نفسها الموضحة في Webhooks: الحداثة، والتحقق من التوقيع بزمن ثابت، وnonce، والتحليل، وإزالة التكرار حسب معرّف الحدث. يظهر مخزن nonce هنا على أنه Redis؛ ويصلح أي مخزن يدعم عملية ذرية “اضبط إن لم يوجد” مع TTL.
Python (Flask)
Section titled “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)
Section titled “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)
Section titled “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)
Section titled “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);الاختبار
Section titled “الاختبار”اطلب من RolChat إرسال حدث اصطناعي موقّع إلى نقطة النهاية لديك:
POST /api/v1/webhooks/test-signatureAuthorization: Bearer pat_…X-Account-Id: <account uuid>إذا أجاب المستقبِل لديك بـ 200 على هذا الاستدعاء وبـ 401 عندما تغيّر بايتًا واحدًا في الجسم، فهو صحيح.