88
99The signed content is ``{timestamp}.{body}`` where *timestamp* is the
1010value of the ``x-commune-timestamp`` header (Unix milliseconds).
11+
12+ Call verify_signature() at the start of every webhook handler before
13+ processing the payload. If it raises WebhookVerificationError, reject
14+ the request with HTTP 401.
1115"""
1216
1317from __future__ import annotations
1923
2024
2125class WebhookVerificationError (Exception ):
22- """Raised when webhook signature verification fails."""
26+ """Raised when webhook signature verification fails.
27+
28+ Catch this in your webhook handler and return HTTP 401 to reject the request.
29+ Do not process the payload if this is raised — the request may be forged or
30+ replayed.
31+
32+ Example:
33+ from commune.webhooks import verify_signature, WebhookVerificationError
34+
35+ try:
36+ verify_signature(payload, signature, secret, timestamp=timestamp)
37+ except WebhookVerificationError:
38+ return Response(status_code=401)
39+ """
2340
2441
2542_V1_PREFIX = "v1="
@@ -30,18 +47,31 @@ def compute_signature(
3047 secret : str ,
3148 timestamp : str ,
3249) -> str :
33- """Compute the expected ``v1=`` signature for a webhook payload.
50+ """Compute the expected HMAC-SHA256 signature for a webhook payload.
3451
35- This is useful for testing or for building your own verification
36- logic. Most callers should use :func:`verify_signature` instead.
52+ This is useful for testing your webhook handler locally or for building
53+ your own custom verification logic. Most callers should use
54+ verify_signature() instead, which handles comparison and timestamp checks.
3755
3856 Args:
39- payload: The raw request body.
40- secret: Your inbox webhook secret.
41- timestamp: The ``x-commune-timestamp`` header value (Unix ms).
57+ payload: The raw request body (bytes or str). Do NOT pass parsed JSON —
58+ the signature is computed over the exact bytes Commune sent.
59+ secret: Your inbox webhook secret (from the Commune dashboard or
60+ inbox.webhook.secret after calling inboxes.set_webhook()).
61+ timestamp: The ``x-commune-timestamp`` header value (Unix milliseconds
62+ as a string, e.g. "1706000000000").
4263
4364 Returns:
44- The full signature string including the ``v1=`` prefix.
65+ The full signature string including the ``v1=`` prefix,
66+ e.g. "v1=5a3f2b1c...". Compare this against the x-commune-signature
67+ header value using hmac.compare_digest() for timing-safe comparison.
68+
69+ Example — generate a test signature for local testing:
70+ import json, time
71+ body = json.dumps({"event": "inbound", "sender": "user@example.com"})
72+ ts = str(int(time.time() * 1000))
73+ sig = compute_signature(body.encode(), secret="whsec_...", timestamp=ts)
74+ # sig → "v1=a1b2c3..."
4575 """
4676 if isinstance (payload , str ):
4777 payload = payload .encode ("utf-8" )
@@ -65,42 +95,105 @@ def verify_signature(
6595) -> bool :
6696 """Verify a Commune webhook signature.
6797
68- Commune signs every webhook delivery with HMAC-SHA256. The signature
69- header uses the format ``v1={hex_digest}`` and the timestamp header
70- contains Unix **milliseconds**.
98+ Call this at the start of every webhook handler before processing the payload.
99+ Commune signs outbound webhooks with HMAC-SHA256 using your inbox webhook secret.
100+ Verifying the signature ensures the request came from Commune and was not
101+ tampered with in transit, and that it is not a replayed old request.
102+
103+ IMPORTANT: Pass the raw request body (bytes), not parsed JSON. Many frameworks
104+ parse the body before it reaches your handler — make sure to read raw bytes first.
105+
106+ Flask: request.get_data()
107+ FastAPI: await request.body()
108+ Django: request.body
109+ Express: Use express.raw({ type: "*/*" }) middleware
71110
72111 Args:
73- payload: The raw request body (bytes or string).
74- signature: The ``x-commune-signature`` header value
75- (e.g. ``"v1=5a3f2b..."``).
76- secret: Your inbox webhook secret.
77- timestamp: The ``x-commune-timestamp`` header value (Unix ms).
78- **Required** for proper verification — the backend always
79- includes the timestamp in the signed content. If omitted,
80- the signature is verified against the raw payload only
81- (not recommended).
82- tolerance_seconds: Maximum age of the webhook in seconds
83- (default 300 = 5 minutes). Only used when ``timestamp``
84- is provided.
112+ payload: Raw request body bytes (NOT parsed JSON). The signature is
113+ computed over the exact byte sequence Commune sent.
114+ signature: Value of the ``x-commune-signature`` header.
115+ Format: "v1={hex_digest}" (e.g. "v1=5a3f2b1c...").
116+ secret: Your inbox webhook secret. Get this from the Commune dashboard
117+ or from inbox.webhook.secret after calling inboxes.set_webhook().
118+ Store it as an environment variable — do not hard-code it.
119+ timestamp: Value of the ``x-commune-timestamp`` header (Unix milliseconds).
120+ REQUIRED for proper verification — Commune always includes the
121+ timestamp in the signed content. If omitted, the signature is
122+ verified against the raw payload only (not recommended).
123+ tolerance_seconds: Maximum age of the webhook in seconds before it is
124+ rejected as a replay (default 300 = 5 minutes).
125+ Set to 0 to disable freshness checking.
85126
86127 Returns:
87- `` True`` if the signature is valid.
128+ True if the signature is valid and the timestamp is fresh .
88129
89130 Raises:
90- WebhookVerificationError: If the signature is invalid or the
91- timestamp is too old.
92-
93- Example::
94-
95- from commune.webhooks import verify_signature
96-
97- # In your webhook handler (e.g. Flask / FastAPI):
98- verify_signature(
99- payload=request.body,
100- signature=request.headers["x-commune-signature"],
101- secret="whsec_...",
102- timestamp=request.headers.get("x-commune-timestamp"),
103- )
131+ WebhookVerificationError: If the signature is invalid, the secret is
132+ missing, or the timestamp is too old (> tolerance_seconds).
133+ If this raises, reject the request with HTTP 401. Do not process
134+ the payload.
135+
136+ Example — Flask:
137+ from flask import Flask, request
138+ from commune.webhooks import verify_signature, WebhookVerificationError
139+ import os
140+
141+ app = Flask(__name__)
142+
143+ @app.post("/webhook")
144+ def handle():
145+ try:
146+ verify_signature(
147+ payload=request.get_data(), # ← raw bytes, not request.json
148+ signature=request.headers["x-commune-signature"],
149+ secret=os.environ["COMMUNE_WEBHOOK_SECRET"],
150+ timestamp=request.headers.get("x-commune-timestamp"),
151+ )
152+ except WebhookVerificationError:
153+ return {"error": "invalid signature"}, 401
154+ # Safe to process now
155+ data = request.json
156+ return process_email(data)
157+
158+ Example — FastAPI:
159+ from fastapi import FastAPI, Request, HTTPException
160+ from commune.webhooks import verify_signature, WebhookVerificationError
161+ import json, os
162+
163+ app = FastAPI()
164+
165+ @app.post("/webhook")
166+ async def handle(request: Request):
167+ body = await request.body() # ← raw bytes before any parsing
168+ try:
169+ verify_signature(
170+ payload=body,
171+ signature=request.headers["x-commune-signature"],
172+ secret=os.environ["COMMUNE_WEBHOOK_SECRET"],
173+ timestamp=request.headers.get("x-commune-timestamp"),
174+ )
175+ except WebhookVerificationError:
176+ raise HTTPException(status_code=401, detail="Invalid signature")
177+ payload = json.loads(body)
178+ return await process_email(payload)
179+
180+ Example — Django:
181+ from django.http import HttpResponse, JsonResponse
182+ from commune.webhooks import verify_signature, WebhookVerificationError
183+ import json, os
184+
185+ def webhook(request):
186+ try:
187+ verify_signature(
188+ payload=request.body, # ← raw bytes
189+ signature=request.headers.get("X-Commune-Signature", ""),
190+ secret=os.environ["COMMUNE_WEBHOOK_SECRET"],
191+ timestamp=request.headers.get("X-Commune-Timestamp"),
192+ )
193+ except WebhookVerificationError:
194+ return HttpResponse(status=401)
195+ data = json.loads(request.body)
196+ return JsonResponse(process_email(data))
104197 """
105198 if not signature :
106199 raise WebhookVerificationError ("Missing signature" )
0 commit comments