Skip to content

Commit 76cc12d

Browse files
authored
Add a verify_sendgrid_webhook_signature decorator to make Sendgrid Events webhook development easier (#138)
* Add a verify_sendgrid_webhook_signature decorator to make Sendgrid Events webhook development easier * Add tests for webhook signature verification decorator * Document new webhook signature verification decorator
1 parent 5188d58 commit 76cc12d

4 files changed

Lines changed: 204 additions & 0 deletions

File tree

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,59 @@ msg.ip_pool_name = 'my-ip-pool'
109109
msg.send(fail_silently=False)
110110
```
111111

112+
### Webhook Helpers
113+
114+
Version 6 of the `sendgrid` package or later includes some helper functions to
115+
cryptographically verify the signature and contents of events from the Sendgrid
116+
Events webhook.
117+
118+
This project includes some additional helpers for Sendgrid's webhook signature
119+
verification.
120+
121+
1. Enable signature verification for Sendgrid webhooks (see [Sendgrid docs](https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features#enable-signature-verification)). Once you have saved the webhook and edited it again, copy the verification key.
122+
2. Modify your project's `settings.py` and set `SENDGRID_WEBHOOK_VERIFICATION_KEY` to your verification key value.
123+
3. Setup a project URLConf and view. Below is an example view you can adapt to your needs.
124+
125+
```python
126+
import json
127+
from datetime import datetime
128+
129+
from django.db import transaction
130+
from django.http import HttpRequest, HttpResponse
131+
from django.views.decorators.csrf import csrf_exempt
132+
from django.views.decorators.http import require_POST
133+
from post_office.models import Email, Log as EmailLog, STATUS
134+
from pytz import utc
135+
from sendgrid_backend.decorators import verify_sendgrid_webhook_signature
136+
137+
EVENTS = {'delivered': STATUS.sent, 'bounce': STATUS.failed, 'blocked': STATUS.failed}
138+
139+
@csrf_exempt
140+
@require_POST
141+
@verify_sendgrid_webhook_signature
142+
def sendgrid_deliverability_webhook_handler(request: HttpRequest) -> HttpResponse:
143+
"""
144+
Example webhook handler to save delivered, bounce, and blocked events to
145+
the email log.
146+
"""
147+
for msg_dict in reversed(json.loads(request.body)):
148+
if event := EVENTS.get(msg_dict.get('event', None), None):
149+
event_timestamp = datetime.fromtimestamp(msg_dict.get('timestamp'), tz=utc)
150+
with transaction.atomic():
151+
Email.objects.filter(message_id=msg_dict.get('smtp-id', None)).update(
152+
last_updated=event_timestamp,
153+
status=event,
154+
)
155+
156+
EmailLog.objects.create(
157+
email__message_id=msg_dict.get('smtp-id', None),
158+
date=event_timestamp,
159+
status=event,
160+
message=json.dumps(msg_dict),
161+
)
162+
return HttpResponse("ok")
163+
```
164+
112165

113166
### FAQ
114167
**How to change a Sender's Name ?**

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ legacy_tox_ini = """
7777
django51: Django>=5.1,<5.2
7878
sendgrid5: sendgrid>=5,<6
7979
sendgrid6: sendgrid>=6,<7
80+
starkbank-ecdsa
8081
pytest-cov
8182
8283
commands =

sendgrid_backend/decorators.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from functools import wraps
2+
from inspect import iscoroutinefunction
3+
from typing import Callable
4+
5+
from django.conf import settings
6+
from django.http import HttpResponseNotFound
7+
8+
from sendgrid_backend.util import SENDGRID_6
9+
10+
if SENDGRID_6:
11+
from sendgrid.helpers.eventwebhook import EventWebhook
12+
from sendgrid.helpers.eventwebhook.eventwebhook_header import EventWebhookHeader
13+
14+
# Adapted from:
15+
# https://stackoverflow.com/a/71672552
16+
def check_sendgrid_signature(request):
17+
event_webhook = EventWebhook()
18+
key = settings.SENDGRID_WEBHOOK_VERIFICATION_KEY
19+
ec_public_key = event_webhook.convert_public_key_to_ecdsa(key)
20+
21+
return event_webhook.verify_signature(
22+
request.body.decode("utf-8"),
23+
request.headers[EventWebhookHeader.SIGNATURE],
24+
request.headers[EventWebhookHeader.TIMESTAMP],
25+
ec_public_key,
26+
)
27+
28+
def verify_sendgrid_webhook_signature(func: Callable) -> Callable:
29+
"""Check a view for a valid sendgrid webhook"""
30+
if iscoroutinefunction(func):
31+
32+
@wraps(func)
33+
async def inner(request, *args, **kwargs):
34+
if not check_sendgrid_signature(request):
35+
return HttpResponseNotFound()
36+
return await func(request, *args, **kwargs)
37+
38+
else:
39+
40+
@wraps(func)
41+
def inner(request, *args, **kwargs):
42+
if not check_sendgrid_signature(request):
43+
return HttpResponseNotFound()
44+
return func(request, *args, **kwargs)
45+
46+
return inner

test/test_decorators.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from unittest.mock import MagicMock
2+
3+
from django.http import HttpResponseNotFound
4+
from django.test import SimpleTestCase
5+
6+
from sendgrid_backend.util import SENDGRID_6
7+
8+
if SENDGRID_6:
9+
from ellipticcurve.ecdsa import Ecdsa
10+
from ellipticcurve.privateKey import PrivateKey
11+
from sendgrid.helpers.eventwebhook.eventwebhook_header import EventWebhookHeader
12+
13+
from sendgrid_backend.decorators import (
14+
check_sendgrid_signature,
15+
verify_sendgrid_webhook_signature,
16+
)
17+
18+
# Generate privateKey from PEM string
19+
PRIVATE_KEY = PrivateKey.fromPem(
20+
"""
21+
-----BEGIN EC PARAMETERS-----
22+
BgUrgQQACg==
23+
-----END EC PARAMETERS-----
24+
-----BEGIN EC PRIVATE KEY-----
25+
MHQCAQEEIODvZuS34wFbt0X53+P5EnSj6tMjfVK01dD1dgDH02RzoAcGBSuBBAAK
26+
oUQDQgAE/nvHu/SQQaos9TUljQsUuKI15Zr5SabPrbwtbfT/408rkVVzq8vAisbB
27+
RmpeRREXj5aog/Mq8RrdYy75W9q/Ig==
28+
-----END EC PRIVATE KEY-----
29+
"""
30+
)
31+
PUBLIC_KEY_STRING = "".join(PRIVATE_KEY.publicKey().toPem().splitlines()[2:-1])
32+
TIMESTAMP = "2025-05-13 07:42:18.792332+00:00"
33+
34+
class TestDecoratorTestCase(SimpleTestCase):
35+
@classmethod
36+
def setUpClass(cls):
37+
cls.message = "this data should be signed"
38+
39+
cls.good_request = MagicMock()
40+
cls.good_request.body.decode.return_value = "this data should be signed"
41+
cls.good_request.headers = {
42+
EventWebhookHeader.SIGNATURE: Ecdsa.sign(
43+
TIMESTAMP + cls.message, PRIVATE_KEY
44+
).toBase64(),
45+
EventWebhookHeader.TIMESTAMP: TIMESTAMP,
46+
}
47+
48+
cls.bad_request = MagicMock()
49+
cls.bad_request.body.decode.return_value = "this data should be signed"
50+
cls.bad_request.headers = {
51+
EventWebhookHeader.SIGNATURE: Ecdsa.sign(
52+
TIMESTAMP + cls.message, PRIVATE_KEY
53+
).toBase64(),
54+
EventWebhookHeader.TIMESTAMP: TIMESTAMP + "A", # One character off
55+
}
56+
57+
def test_check_sendgrid_signature(self):
58+
with self.settings(SENDGRID_WEBHOOK_VERIFICATION_KEY=PUBLIC_KEY_STRING):
59+
self.assertTrue(check_sendgrid_signature(self.good_request))
60+
61+
def test_check_sendgrid_signature_bad_signature(self):
62+
with self.settings(SENDGRID_WEBHOOK_VERIFICATION_KEY=PUBLIC_KEY_STRING):
63+
self.assertFalse(check_sendgrid_signature(self.bad_request))
64+
65+
def test_verify_sendgrid_webhook_signature_decorator(self):
66+
@verify_sendgrid_webhook_signature
67+
def test_func(request):
68+
return "The function was successfully run"
69+
70+
with self.settings(SENDGRID_WEBHOOK_VERIFICATION_KEY=PUBLIC_KEY_STRING):
71+
self.assertEqual(
72+
test_func(self.good_request), "The function was successfully run"
73+
)
74+
75+
def test_verify_sendgrid_webhook_signature_decorator_bad_signature(self):
76+
@verify_sendgrid_webhook_signature
77+
def test_func(request):
78+
return "The function was successfully run"
79+
80+
with self.settings(SENDGRID_WEBHOOK_VERIFICATION_KEY=PUBLIC_KEY_STRING):
81+
self.assertIsInstance(test_func(self.bad_request), HttpResponseNotFound)
82+
83+
async def test_async_verify_sendgrid_webhook_signature_decorator(self):
84+
@verify_sendgrid_webhook_signature
85+
async def test_func(request):
86+
return "The function was successfully run"
87+
88+
with self.settings(SENDGRID_WEBHOOK_VERIFICATION_KEY=PUBLIC_KEY_STRING):
89+
self.assertEqual(
90+
await test_func(self.good_request),
91+
"The function was successfully run",
92+
)
93+
94+
async def test_async_verify_sendgrid_webhook_signature_decorator_bad_signature(
95+
self,
96+
):
97+
@verify_sendgrid_webhook_signature
98+
async def test_func(request):
99+
return "The function was successfully run"
100+
101+
with self.settings(SENDGRID_WEBHOOK_VERIFICATION_KEY=PUBLIC_KEY_STRING):
102+
self.assertIsInstance(
103+
await test_func(self.bad_request), HttpResponseNotFound
104+
)

0 commit comments

Comments
 (0)