Tutorial: Push live UI updates from a webhook (Stripe, GitHub, etc.)
Receive a webhook from Stripe (or GitHub, or any external service) and surface the event in connected users' dashboards within ~50ms — no polling, no client-side fetching. Uses push_to_view() to ship state updates from a plain Django view straight into every connected LiveView session.
On this page
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, or, with scope=, only on the
sessions that joined that scope. 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_path, state={...}) for state updates | Server Push |
push_to_view(view_path, handler="handle_...", payload={...}) for handler invocations | Server Push |
push_scope + scope= to reach one customer's dashboard, not all | Server Push |
| Webhook signature verification (the security part) | This tutorial |
Prerequisites: Installation, 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 models and the LiveView (no webhook code yet)
Two models: the account whose balance the dashboard shows, and a record of every webhook event already applied, which Step 2 uses to ignore retries.
# myapp/models.py
from django.conf import settings
from django.db import models
class Account(models.Model):
owner = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
balance_cents = models.BigIntegerField(default=0)
class ProcessedWebhookEvent(models.Model):
# The provider's event id (Stripe's evt_..., GitHub's delivery id).
# unique=True is what makes a replayed delivery a no-op.
event_id = models.CharField(max_length=255, unique=True)
received_at = models.DateTimeField(auto_now_add=True)
The view:
# myapp/views.py
from djust import LiveView, state
from .models import Account
class AccountView(LiveView):
template_name = "account.html"
login_required = True # anonymous visitors are sent to login_url
login_url = "/login/"
balance_cents = state(0)
last_event = state("")
def mount(self, request, **kwargs):
account = Account.objects.get(owner=request.user)
self.balance_cents = account.balance_cents
# This session receives scoped pushes for its own account only.
self.push_scope = f"account-{account.id}"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["balance"] = f"{self.balance_cents / 100:.2f}"
return context
def handle_balance_changed(self, new_balance_cents: int = 0, description: str = "", **kwargs):
"""Called when push_to_view targets this view with
handler="handle_balance_changed". The webhook view fires this."""
self.balance_cents = new_balance_cents
self.last_event = description or "Balance updated"
handle_balance_changed is a plain method, not an @event_handler,
so a client click can't call it. It's invoked by
push_to_view(handler="handle_balance_changed", payload={...}),
and the payload's keys arrive as keyword arguments. A pushed handler
must start with handle_ (or be an @event_handler); djust blocks
any other name. That's the distinction: @event_handler for
client-driven events; handle_* methods for server-driven
(push-driven) events.
Step 2 — The webhook view
<!-- The stripe package is a third-party dependency the doc checker does not install. --># myapp/webhooks.py
import json
import logging
import stripe
from django.conf import settings
from django.db import transaction
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, ProcessedWebhookEvent
logger = logging.getLogger(__name__)
stripe.api_key = settings.STRIPE_SECRET_KEY
# Balances are kept in one currency (the page renders them in dollars).
# Stripe reports currencies as lower-case ISO codes.
ACCOUNT_CURRENCY = "usd"
@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.SignatureVerificationError):
return HttpResponseBadRequest("Invalid signature")
if event["type"] != "payment_intent.succeeded":
return HttpResponse(status=200)
intent = event["data"]["object"]
amount = intent["amount"] # in cents
try:
account_id = int(intent["metadata"].get("account_id", ""))
except (TypeError, ValueError):
# A 4xx/5xx makes Stripe retry for days; this event will never
# succeed, so log it and acknowledge it.
logger.warning("Stripe event %s has no usable account_id", event["id"])
return HttpResponse(status=200)
with transaction.atomic():
# Stripe delivers at least once and retries on timeouts. Recording
# the event id in the same transaction as the credit makes a
# retry a no-op instead of a second credit.
_, created = ProcessedWebhookEvent.objects.get_or_create(event_id=event["id"])
if not created:
return HttpResponse(status=200)
# 1000 in cents is $10.00 but ¥1000 in yen: never add an amount in
# another currency to the balance. The event id is already
# recorded, so a redelivery is skipped too.
if intent.get("currency") != ACCOUNT_CURRENCY:
logger.warning(
"Stripe event %s is in %r, not %r; not credited",
event["id"], intent.get("currency"), ACCOUNT_CURRENCY,
)
return HttpResponse(status=200)
account = Account.objects.select_for_update().filter(pk=account_id).first()
if account is None:
logger.warning(
"Stripe event %s names unknown account %s", event["id"], account_id
)
return HttpResponse(status=200)
account.balance_cents += amount
account.save(update_fields=["balance_cents"])
# Push to the AccountView sessions of this account only
push_to_view(
"myapp.views.AccountView",
handler="handle_balance_changed",
scope=f"account-{account.id}",
payload={
"new_balance_cents": account.balance_cents,
"description": f"Payment received: ${amount / 100:.2f}",
},
)
return HttpResponse(status=200)
Five 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_path, handler=..., payload=..., scope=...)is the one new API. It sends one Channels group message; every connectedAccountViewsession in that scope receives it, and the framework calls the named handler with the payload as keyword arguments, then re-renders and sends the patch.scope=picks the sessions. EachAccountViewjoinedaccount-<id>inmount()by settingpush_scope, so the push reaches only the paying customer's open dashboards. Withoutscope, it would reach everyAccountViewsession.- Idempotency. Stripe delivers each event at least once and
retries when your endpoint is slow or fails, so the same
payment_intent.succeededcan arrive twice. TheProcessedWebhookEventrow is written in the same transaction as the credit, and itsuniqueevent id makes a second delivery (even a concurrent one) find the existing row and return without crediting again. - Always acknowledge what you can't process. Any non-2xx response
makes Stripe retry. An event naming an account you don't have will
never succeed, so the view logs it and returns 200 instead of
raising
DoesNotExist(a 500 Stripe would keep retrying). A payment in a currency other thanACCOUNT_CURRENCYgets the same treatment: logged, not credited, acknowledged. If you take several currencies, store a currency per account and compare against that instead.
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 -->
<div dj-root>
<section class="account">
<h1>Your account</h1>
<p class="balance">
Balance: <strong>${{ balance }}</strong>
</p>
{% if last_event %}
<p class="last-event" role="status">{{ last_event }}</p>
{% endif %}
</section>
</div>
dj-root marks the region djust keeps live; without it the page renders
once and never connects, so pushes have nowhere to land. balance is the dollars string get_context_data() builds from
balance_cents. 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", "")
delivery_id = request.META.get("HTTP_X_GITHUB_DELIVERY", "")
payload = json.loads(request.body)
if event_type == "pull_request" and payload.get("action") == "opened":
# GitHub redelivers too; skip a delivery id we've already handled.
if delivery_id:
_, created = ProcessedWebhookEvent.objects.get_or_create(
event_id=f"github:{delivery_id}"
)
if not created:
return HttpResponse(status=200)
repo = payload["repository"]["full_name"]
pr_number = payload["number"]
push_to_view(
"myapp.views.RepoDashboardView",
handler="handle_pr_opened",
scope=repo, # dashboards that set push_scope to this repo
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
Without scope, push_to_view reaches ALL connected sessions of
the view class. Each of them runs the handler and re-renders, so
the work grows with the number of open sessions, and every session
would receive another customer's balance.
The webhook view doesn't know which user owns which WebSocket
session, and it doesn't need to. The LiveView decides, in mount(),
which scope its session belongs to (self.push_scope), after the
view's own access checks (login_required) have run. The webhook
then pushes to the scope:
push_to_view(
"myapp.views.AccountView",
handler="handle_balance_changed",
scope=f"account-{account.id}",
payload={...},
)
Keep the scope derived from data the session was allowed to see
(here, the signed-in user's own account), never from a request
parameter. A scope is a str or an int; a session can be in up to
64 of them.
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="handle_balance_changed", │
│ │ scope="account-1", payload={...}) │
│ │ ─── group_send (scope group) ──► │
│ │ │ handle_balance_changed(**payload)
│ │ │ 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
- Several views, one event: scopes belong to one view path. If a payment should also update, say, an admin view, push to each view path separately.
- Pruning processed events:
ProcessedWebhookEventgrows by one row per event. Stripe stops retrying after about three days, so a periodic job can delete rows older than a week. - 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. See Deployment for the channel-layer settings. Server push reaches WebSocket sessions only; SSE and HTTP-only sessions receive none.
The two-line shape — push_to_view(view, handler="handle_...", scope=..., payload=...) from the webhook + a plain def handle_...(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.