← Webhook localhost by framework
Django webhook localhost
Local run command
Django’s manage.py runserver defaults to 8000. That collides with FastAPI’s Uvicorn default if both run on one machine — pick one process per port.
python manage.py runserver 127.0.0.1:8000127.0.0.1:8000 matches Mercur’s agent (hostname: "127.0.0.1"). runserver 0.0.0.0:8000 still accepts that dial. Endpoint Local port is 8000.
Webhook route
Two files, not one: a view marked csrf_exempt, and a urlpatterns entry whose path equals the Stripe Dashboard URL path ({findFrameworkArticle("django").webhookPath}). APPEND_SLASH (on by default) 301s POST /webhooks/stripe to /webhooks/stripe/ and Stripe will not replay the body onto the redirect. Use a path without a trailing slash in both Django and the Dashboard, or both with a slash — not mixed.
import hmac
import os
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
@csrf_exempt
@require_POST
def stripe_webhook(request):
# Form-decoded POST data is the wrong bytes. Stripe signed request.body.
raw_body = request.body
signature = request.headers.get("Stripe-Signature")
if not signature:
return HttpResponseBadRequest("missing Stripe-Signature")
try:
event = stripe.Webhook.construct_event(
raw_body, signature, os.environ["STRIPE_WEBHOOK_SECRET"]
)
except stripe.error.SignatureVerificationError:
return HttpResponseForbidden("invalid signature")
except ValueError:
return HttpResponseBadRequest("invalid payload")
return HttpResponse(status=200, content=f"received {event['id']}")
from django.urls import path
from . import views
urlpatterns = [
# Match the provider URL path exactly. APPEND_SLASH will 301 a POST and drop the body.
path("webhooks/stripe", views.stripe_webhook),
]
Include webhooks.urls from the project urls.py. A missing include is webhook 404.
Raw body and signatures
@csrf_exempt is required because Stripe is not a browser and will not send a CSRF cookie. Without it Django returns 403 (often described as 400 in dashboards).
Use request.body (bytes). request.POST is for form encodings. json.loads(request.body) after constructEvent is fine.
Stripe constructEvent uses t.<raw body> HMAC; timestamp skew still fails on an old Retry. See webhook signature verification failed.
Django REST Framework’s JSONParser on an APIView has the same parse-first problem. A function view with request.body avoids the parser.
Connect localhost
Mercur forwards the public Host header. Django then compares it to ALLOWED_HOSTS. DEBUG=True still does not allow ep-webhooks.mercur.sh. That miss is DisallowedHost → HTTP 400:
# Mercur's agent dials 127.0.0.1 but forwards the public Host header
# (ep-webhooks.mercur.sh), not localhost. DisallowedHost is HTTP 400.
ALLOWED_HOSTS = [
"127.0.0.1",
"localhost",
".mercur.sh",
]
Then:
- Endpoint Local port
{findFrameworkArticle("django").localPort}. mercur author the macOS app.- Start:
mercur start ep-webhooks- Stripe endpoint URL
https://ep-webhooks.mercur.sh/webhooks/stripe.
Forwarding needs a connected agent — the macOS app or the npm package @mercur_dev/cli.
.mercur.sh with a leading dot is Django’s documented subdomain match. Do not put that allowlist in production if this settings module is shared with a public site — use an env-specific settings file.
Expected incoming request
runserver logs POST /webhooks/stripe 200 for a Stripe test event:
POST {findFrameworkArticle("django").webhookPath}Stripe-SignatureHost: ep-webhooks.mercur.sh— this is whyALLOWED_HOSTSmust include.mercur.shContent-Type: application/json- Body
id/type
If you see Invalid HTTP_HOST header in the console, the tunnel worked and Django refused the host. That is not webhook not received.
Guest inspector never hits runserver and always returns 204.
Inspect and replay
History should show {findFrameworkArticle("django").webhookPath} (no surprise trailing slash). Cap 256 KB.
Retry after CSRF exemption and ALLOWED_HOSTS are fixed. Proxy mode, agent connected. Replay of a DisallowedHost 400 will keep 400 until settings reload (runserver autoreload picks up ALLOWED_HOSTS).
Inspect the request Connect localhost
Provider examples
- Test Stripe webhooks locally — Dashboard URL and signing secret
- How to test webhooks — inspector vs Endpoint
- Webhook 400 — CSRF and
DisallowedHostboth surface as 400 to Stripe
Framework troubleshooting
- 403 CSRF — view is not
@csrf_exempt, or a middleware stack re-enables CSRF after the decorator.require_POSTdoes not replace the exemption. - 400
DisallowedHost—ALLOWED_HOSTSis still["localhost"]. Mercur will not rewrite Host to localhost. - 301 then Stripe failure —
APPEND_SLASHvs Dashboard URL slash mismatch. - 400 on JSON —
request.POSTused instead ofrequest.body. - 502 —
manage.py runservernot running, or Endpoint still on 3000 from a Next.js experiment.curl -i http://127.0.0.1:8000/webhooks/stripe. - CommonMiddleware
SECURE_SSL_REDIRECT— can 301 HTTP to HTTPS on the local hop. Leave it off forrunserver.
Related
- FastAPI webhook localhost
- Next.js webhook localhost
- Express webhook localhost
- Webhook 400
- Webhook signature verification failed
- Webhook 404
- Webhook 502 localhost
- Test Stripe webhooks locally
- How to test webhooks
- Test GitHub webhooks locally
- Expose a local service
- CLI agent: forward localhost with npm
- Replay a stored request
- Online webhook inspector