← Webhook localhost by framework

Django webhook localhost

Django rejects POSTs that have no CSRF cookie. A webhook view needs @csrf_exempt. Mercur also forwards the public Host header (not localhost), so ALLOWED_HOSTS must include .mercur.sh or DisallowedHost returns 400.

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.

Run
python manage.py runserver 127.0.0.1:8000

127.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.

webhooks/views.py
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']}")
webhooks/urls.py
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:

settings.py (ALLOWED_HOSTS excerpt)
# 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:

  1. Endpoint Local port {findFrameworkArticle("django").localPort}.
  2. mercur auth or the macOS app.
  3. Start:
Run
mercur start ep-webhooks
  1. 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-Signature
  • Host: ep-webhooks.mercur.sh — this is why ALLOWED_HOSTS must include .mercur.sh
  • Content-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

Framework troubleshooting

  • 403 CSRF — view is not @csrf_exempt, or a middleware stack re-enables CSRF after the decorator. require_POST does not replace the exemption.
  • 400 DisallowedHostALLOWED_HOSTS is still ["localhost"]. Mercur will not rewrite Host to localhost.
  • 301 then Stripe failureAPPEND_SLASH vs Dashboard URL slash mismatch.
  • 400 on JSONrequest.POST used instead of request.body.
  • 502manage.py runserver not 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 for runserver.

Related