Tutorial: Push live UI updates from a webhook (Stripe, GitHub, etc.)
External services don't speak WebSocket. Stripe POSTs to your
/webhooks/stripe/ endpoint when a payment succeeds. GitHub
POSTs to /webhooks/github/ when a PR opens. Slack POSTs to
/slack/events/ when someone reacts. Each one is a regular HTTP
request hitting a regular Django view.
The interesting question: how does a payment-succeeded webhook
update the dashboard for the customer who just paid? They're
already on /dashboard/, watching their balance. Polling every
5 seconds is wasteful. Refreshing on visibilitychange is
laggy. The right shape is push the update from the webhook
view straight into their open LiveView session.
djust ships push_to_view() for exactly this. Any backend
code (a webhook view, a Celery task, a management command, a
Django signal) can push state updates or fire handlers on every
connected LiveView of a given class. The framework routes
through Channels.
By the end of this tutorial you'll have:
- A
/webhooks/stripe/endpoint that verifies Stripe's signature and processes apayment_intent.succeededevent. - An
AccountViewLiveView showing the user's balance. - When the webhook fires, the customer's open dashboard updates within ~50 ms — no polling, no fetch, no manual refresh.
- The same pattern applied to a GitHub
pull_requestwebhook that flips a PR's status badge in real time.
| You'll learn | Documented in |
|---|---|
push_to_view(view_class, state={...}) for state updates | Server Push |
push_to_view(view_class, handler="...", payload={...}) for handler invocations | Server Push |
| Filtering pushes to specific users (one customer's dashboard, not all) | This tutorial |
| Webhook signature verification (the security part) | This tutorial |
Prerequisites: Quickstart, the streaming AI tutorial (sets up the background-work mental model), and a Django project with Channels configured (which you already have if you're running djust). For Stripe specifically, a Stripe test account and the
stripePython SDK.
Step 1 — The LiveView (no webhook code yet)
# myapp/views.py
from djust import LiveView, state
from .models import Account
class AccountView(LiveView):
template_name = "account.html"
balance_cents = state(0)
last_event = state("")
def mount(self, request, **kwargs):
if not request.user.is_authenticated:
self.redirect("/login/")
return
self.account = Account.objects.get(owner=request.user)
self.balance_cents = self.account.balance_cents
def on_balance_changed(self, payload, **kwargs):
"""Called when push_to_view targets this view with
handler='on_balance_changed'. The webhook view fires this."""
if payload.get("account_id") != self.account.id:
return # not for this user
self.balance_cents = payload["new_balance_cents"]
self.last_event = payload.get("description", "Balance updated")
on_balance_changed is a regular method, not an @event_handler.
It's invoked by push_to_view(handler="on_balance_changed", payload={...}) rather than by client clicks. That's the
distinction: @event_handler for client-driven events;
plain methods for server-driven (push-driven) events.
Step 2 — The webhook view
# myapp/webhooks.py
import json
import stripe
from django.conf import settings
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from djust import push_to_view
from .models import Account
stripe.api_key = settings.STRIPE_SECRET_KEY
@csrf_exempt # Webhooks come from Stripe, not your form. CSRF
@require_POST # cookie won't be present. Verify the signature instead.
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, stripe.error.SignatureVerificationError):
return HttpResponseBadRequest("Invalid signature")
if event["type"] == "payment_intent.succeeded":
intent = event["data"]["object"]
account_id = int(intent["metadata"].get("account_id", 0))
amount = intent["amount"] # in cents
# Persist
account = Account.objects.select_for_update().get(pk=account_id)
account.balance_cents += amount
account.save()
# Push to every connected AccountView session
push_to_view(
"myapp.views.AccountView",
handler="on_balance_changed",
payload={
"account_id": account.id,
"new_balance_cents": account.balance_cents,
"description": f"Payment received: ${amount / 100:.2f}",
},
)
return HttpResponse(status=200)
Three things to call out:
@csrf_exemptis required because the request comes from Stripe, not your form. Don't worry —stripe.Webhook.construct_eventverifies the cryptographic signature, which is stronger than CSRF. Same applies to GitHub webhooks (HMAC-SHA256 inX-Hub-Signature-256), Slack (X-Slack-Signature), etc.push_to_view(view_class_path, handler=..., payload=...)is the one new API. It looks up every connected session ofAccountView, queues a Channels message for each, and the framework calls the named handler with the payload.- The handler filters by
account_iditself —push_to_viewbroadcasts to ALLAccountViewsessions; the handler decides "is this update for me?" The alternative (filtering on the push side) requires knowing which user owns each session, which the framework doesn't expose to ad-hoc backend code.
Step 3 — Wire the URL
# myapp/urls.py
from django.urls import path
from .views import AccountView
from .webhooks import stripe_webhook
urlpatterns = [
path("account/", AccountView.as_view()),
path("webhooks/stripe/", stripe_webhook, name="stripe_webhook"),
]
The webhook URL needs to be reachable from the public internet.
For local dev: use stripe listen --forward-to localhost:8000/webhooks/stripe/
or ngrok to tunnel.
Step 4 — The template (almost incidental)
<!-- myapp/templates/account.html -->
<section class="account">
<h1>Your account</h1>
<p class="balance">
Balance: <strong>${{ balance_cents|divisibleby:100 }}.00</strong>
</p>
{% if last_event %}
<p class="last-event" role="status">{{ last_event }}</p>
{% endif %}
</section>
Standard LiveView template. The user opens /account/, sees
their current balance. They make a payment in another tab (or
elsewhere). Stripe POSTs to /webhooks/stripe/. The webhook
view persists + pushes. Within ~50 ms, the balance on the open
dashboard updates and the "last_event" line shows "Payment
received: $19.99."
Step 5 — Try it locally
In one terminal: run djust dev server. In another: forward Stripe webhooks:
stripe listen --forward-to localhost:8000/webhooks/stripe/
# → outputs the webhook signing secret; put that in settings.STRIPE_WEBHOOK_SECRET
In a third terminal: trigger a test payment:
stripe trigger payment_intent.succeeded \
--add payment_intent:metadata.account_id=1
Watch the connected browser. The balance line updates. No reload, no manual fetch.
Same pattern, different webhook
GitHub pull_request.opened → flip a "PRs awaiting review" badge:
# myapp/webhooks.py
import hashlib, hmac
from django.conf import settings
def _verify_github(request) -> bool:
sig = request.META.get("HTTP_X_HUB_SIGNATURE_256", "")
expected = "sha256=" + hmac.new(
settings.GITHUB_WEBHOOK_SECRET.encode(),
request.body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, expected)
@csrf_exempt
@require_POST
def github_webhook(request):
if not _verify_github(request):
return HttpResponseBadRequest("Invalid signature")
event_type = request.META.get("HTTP_X_GITHUB_EVENT", "")
payload = json.loads(request.body)
if event_type == "pull_request" and payload["action"] == "opened":
repo = payload["repository"]["full_name"]
pr_number = payload["number"]
push_to_view(
"myapp.views.RepoDashboardView",
handler="on_pr_opened",
payload={"repo": repo, "pr_number": pr_number},
)
return HttpResponse(status=200)
The shape is identical: receive POST, verify signature, process
event, push_to_view. Each external service has its own
signature verification scheme; the rest of the file is the same.
Filtering and authorization concerns
push_to_view broadcasts to ALL connected sessions of the
view class. The handler filters by user/account/whatever. This
is the right boundary because the webhook view doesn't know
which user-id owns which WebSocket session — the LiveView does
(via self.request.user from mount).
DON'T do filtering by trying to look up sessions by user id from
the webhook view. There's no clean API for that, and even if
there were, it'd duplicate the access-control logic that the
LiveView already enforces in mount().
DO filter in the handler:
def on_balance_changed(self, payload, **kwargs):
if payload.get("account_id") != self.account.id:
return
# ... apply update ...
Each user's session has its own self.account from mount; the
handler skips updates not for them. Network cost is one
broadcast per webhook, which is cheap; ~zero CPU per
non-matching session.
What just happened, end to end
Browser Server (HTTP) Server (WS) Stripe
│ │ │ │
│ GET /account/ │ │ │
│ ───────────────────────►│ mount: balance=1000 │ │
│ ◄ render: $10.00 ───────│ │ │
│ │ │ │
│ WS upgrade /ws/ │ │ │
│ ────────────────────────────────────────────────► consumer mounted │
│ │ │ │
│ ... user pays $20 elsewhere ... │
│ │ │ Stripe receives card │
│ │ │ Stripe POSTs ────────►│ webhook
│ │ │ ◄────────────────────│ delivery
│ │ stripe_webhook(request) │ │
│ │ verify signature ✓ │ │
│ │ account.balance += 2000 │
│ │ push_to_view( │ │
│ │ "AccountView", │ │
│ │ handler="on_balance_changed", │
│ │ payload={...}) │ │
│ │ ─────────────────────────► broadcast │
│ │ │ ─── group_send ──► │
│ │ │ on_balance_changed(payload)
│ │ │ filter: account_id matches ✓
│ │ │ self.balance_cents = 3000
│ │ │ render diff │
│ ◄ patch: $10 → $30 ──── │ ─────────────────────── │ │
Two transports: HTTP for the webhook in (one direction, Stripe
→ Django), WebSocket for the patch out (Django → browser).
push_to_view bridges them.
Where to go next
- Per-user pushes: if you only want to push to ONE user's
sessions (not broadcast to all), pair
push_to_viewwith a per-session group. TheLiveViewConsumerjoins a per-user group on mount; webhook view doesgroup_send(f"user_{user_id}", {...})directly. Faster than broadcast-then-filter for high-traffic events. - Idempotency: Stripe (and most webhook providers) retry
failed deliveries. Your handler MUST be idempotent — same
event delivered twice should produce the same final state.
Track event IDs in a
WebhookEventtable; refuse duplicates. - Replay tools: during dev / debugging, store every received webhook (raw body + headers) so you can re-fire it into the handler without coordinating with Stripe again.
- Sentry breadcrumbs: the webhook handler runs in HTTP
context; add
sentry_sdk.set_context("stripe_event", event)so failures attach the originating event for debugging. - Cross-process / multi-server:
push_to_viewuses Channels under the hood, so it works across your fleet automatically — webhook hitting server A reaches the user whose WebSocket landed on server B as long as both share a Redis Channels backend.
The two-line shape — push_to_view(view, handler="...", payload=...) from the webhook + a plain def handler_name(self, payload) on the view — is the entire
"external event → live UI" surface. Once it clicks, every
"can we update X without polling?" question gets a clean
"yes, push from wherever the event arrives" answer.