Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

5 min read

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 a payment_intent.succeeded event.
  • An AccountView LiveView 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_request webhook that flips a PR's status badge in real time.
You'll learnDocumented in
push_to_view(view_class, state={...}) for state updatesServer Push
push_to_view(view_class, handler="...", payload={...}) for handler invocationsServer 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 stripe Python 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:

  1. @csrf_exempt is required because the request comes from Stripe, not your form. Don't worry — stripe.Webhook.construct_event verifies the cryptographic signature, which is stronger than CSRF. Same applies to GitHub webhooks (HMAC-SHA256 in X-Hub-Signature-256), Slack (X-Slack-Signature), etc.
  2. push_to_view(view_class_path, handler=..., payload=...) is the one new API. It looks up every connected session of AccountView, queues a Channels message for each, and the framework calls the named handler with the payload.
  3. The handler filters by account_id itselfpush_to_view broadcasts to ALL AccountView sessions; 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_view with a per-session group. The LiveViewConsumer joins a per-user group on mount; webhook view does group_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 WebhookEvent table; 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_view uses 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.

Spotted a typo or want to improve this page? Edit on GitHub →