A webhook endpoint is an open door. Anyone who learns the URL can send it a request, and the URL is not a secret: it travels through logs, dashboards, and third-party configuration screens. HMAC signature verification is how you tell a real delivery from a forged one.
Why a signature, not an IP allowlist
Providers change their sending IP ranges, and IPs can be spoofed in ways that are hard to reason about. A shared secret and a signature prove the payload came from someone who holds the secret, and that the bytes were not changed along the way.
How HMAC verification works
The provider hashes the raw request body together with a secret only the two of you know, and sends the result in a header. You recompute the same hash and compare. If they match, the request is genuine.
Verify against the raw body
This is the detail that trips people up. You must hash the exact bytes
that were sent, so read request.body, never the parsed
request.data. Re-serializing parsed JSON reorders keys and
changes whitespace, and the signature will never match again.
import base64
import hashlib
import hmac
from django.conf import settings
def verify_shopify_webhook(request):
received = request.headers.get("X-Shopify-Hmac-Sha256", "")
digest = hmac.new(
settings.SHOPIFY_WEBHOOK_SECRET.encode(),
request.body,
hashlib.sha256,
).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(received, expected)
Always use a constant-time comparison
Compare with hmac.compare_digest, not ==. A
plain equality check can return early on the first mismatched
character, and that timing difference leaks information an attacker can
use to guess the signature byte by byte.
Wire it into the view
The endpoint carries no session and no token, so it disables the framework auth classes and relies entirely on the signature.
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
class ShopifyOrderWebhook(APIView):
authentication_classes = []
permission_classes = []
def post(self, request, *args, **kwargs):
if not verify_shopify_webhook(request):
return Response(status=status.HTTP_401_UNAUTHORIZED)
process_order_webhook.delay(request.data)
return Response(status=status.HTTP_200_OK)
Acknowledge fast, process later
The view verifies, pushes the payload to a Celery task, and returns 200 immediately. Most providers retry a webhook if the response is slow, and a retry while the first request is still running means duplicate work. Fast acknowledgement avoids that.
Make the handler idempotent
Webhooks are delivered at least once, which means sometimes more than once. Key the work on the provider event id and skip anything you have already seen. A signature proves who sent the payload. Idempotency keeps a repeat delivery from charging a customer twice.