Skip to content

Commit d2c98c5

Browse files
committed
docs: add intent-focused docstrings to webhooks.py
1 parent 8333334 commit d2c98c5

1 file changed

Lines changed: 131 additions & 38 deletions

File tree

commune/webhooks.py

Lines changed: 131 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
99
The signed content is ``{timestamp}.{body}`` where *timestamp* is the
1010
value 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

1317
from __future__ import annotations
@@ -19,7 +23,20 @@
1923

2024

2125
class 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

Comments
 (0)