---
title: "Tutorial: Push live UI updates from a webhook (Stripe, GitHub, etc.)"
slug: tutorial-webhook-push
section: guides
order: 78
level: advanced
description: "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."
---

# 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 learn | Documented in |
|---|---|
| `push_to_view(view_class, state={...})` for state updates | [Server Push](/advanced/server-push/) |
| `push_to_view(view_class, handler="...", payload={...})` for handler invocations | [Server Push](/advanced/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](/getting-started/), the [streaming
> AI tutorial](/guides/tutorial-streaming-ai/) (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](https://dashboard.stripe.com/)
> and the `stripe` Python SDK.

---

## Step 1 — The LiveView (no webhook code yet)

```python
# 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

```python
# 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` itself** — `push_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

```python
# 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](https://ngrok.com/) to tunnel.

---

## Step 4 — The template (almost incidental)

```html
<!-- 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:

```bash
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:

```bash
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:

```python
# 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:

```python
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.
