Tarkistusesimerkit
Jokainen esimerkki tekee samat viisi asiaa, jotka kuvataan sivulla Webhookit: tuoreus, vakioaikainen allekirjoituksen tarkistus, nonce, jäsennys ja kaksoiskappaleiden poisto tapahtuman tunnisteen perusteella. Nonce-varastona näytetään Redis; mikä tahansa varasto, jossa on atominen set-if-absent-operaatio ja TTL, toimii.
Python (Flask)
Osio nimeltä “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)
Osio nimeltä “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)
Osio nimeltä “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)
Osio nimeltä “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);Testaaminen
Osio nimeltä “Testaaminen”Pyydä RolChatia lähettämään synteettinen allekirjoitettu tapahtuma päätepisteeseesi:
POST /api/v1/webhooks/test-signatureAuthorization: Bearer pat_…X-Account-Id: <account uuid>Jos vastaanottimesi vastaa tähän kutsuun 200 ja palauttaa 401, kun muutat yhden tavun rungosta, se toimii oikein.