# Add a feedback widget to a Django app

> Add the widget script to base.html with defer. Build the signed-in user as a dictionary in a context processor, render it with json_script, and read it in a small static JavaScript file. Allow the widget host in script-src and connect-src with django-csp, so no inline script is needed.

Source: https://escutaproduto.com/resources/feedback-widget-django
Last updated: 2026-10-09

## Add the widget to base.html

Most Django projects keep one base template that every page extends. Put the widget script in its `head` with `defer`, so it loads once per page and never blocks the render:

```django
{% load static %}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}My app{% endblock %}</title>
    <script src="https://escutaproduto.com/widget.js" data-key="pk_your_product_key" defer></script>
    <script src="{% static 'js/escuta-identify.js' %}" defer></script>
  </head>
  <body>
    {% if escuta_user %}
      {{ escuta_user|json_script:"escuta-user" }}
    {% endif %}
    {% block content %}{% endblock %}
  </body>
</html>
```

The `data-key` is your public product key, which can only create feedback. The second script is your own identify file, served from your origin. Both are `defer`, so they run in order after the HTML is parsed: the widget first, then your identify file.

The `json_script` output sits in the body and is not executed, because its type is `application/json`. It only carries data for the script to read.

## Build the user dictionary in a context processor

A context processor adds `escuta_user` to every template, so you do not repeat the code in each view. Put this function in `myproject/context_processors.py` and return a plain dictionary with only the fields the widget needs:

```python
def escuta_user(request):
    user = getattr(request, "user", None)
    if user is None or not user.is_authenticated:
        return {"escuta_user": None}

    return {
        "escuta_user": {
            "id": str(user.pk),
            "email": user.email,
            "name": user.get_full_name() or user.username,
        }
    }
```

Register the function in `TEMPLATES` in `myproject/settings.py`, under the `context_processors` option of your template backend:

```python
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                # ...the defaults your project already has...
                "myproject.context_processors.escuta_user",
            ],
        },
    },
]
```

Returning `str(user.pk)` keeps the id a string, which is how the widget expects it. A user object itself cannot be converted to JSON, so the dictionary is what the template receives. Keep the values small, because they travel in every page response.

## Read the data in a static JavaScript file

The identify code lives in `static/js/escuta-identify.js`, not in the template. The file reads the JSON from the `json_script` element and pushes the call onto the widget queue:

```javascript
(function () {
  var el = document.getElementById("escuta-user");
  if (!el) return;

  var user = JSON.parse(el.textContent);
  window.EscutaProduto = window.EscutaProduto || { q: [] };
  (window.EscutaProduto.q ||= []).push(["identify", [user]]);
})();
```

Reading `textContent` returns the JSON exactly as the server built it, because `json_script` escapes the characters that matter for HTML, such as angle brackets and ampersands. The file has no inline code, so a strict policy only needs to allow your own origin for it.

Avoid writing `{{ request.user.email }}` inside a `<script>` block. Django's autoescaping produces HTML entities, which are not valid inside JavaScript, and an unusual value can still break the page. The filter and the static file avoid both problems.

## Set the Content Security Policy with django-csp

The widget needs its host in two directives. The [django-csp](https://django-csp.readthedocs.io/) package sends the header from settings in `myproject/settings.py`. Version 4.0 and later read a single dictionary, while older releases used separate `CSP_*` settings, so check the version you have installed:

```python
MIDDLEWARE = [
    # ...the middleware you already have...
    "csp.middleware.CSPMiddleware",
]

CONTENT_SECURITY_POLICY = {
    "DIRECTIVES": {
        "default-src": ["'self'"],
        "script-src": ["'self'", "https://escutaproduto.com"],
        "connect-src": ["'self'", "https://escutaproduto.com"],
    }
}
```

Because the identify code is in a static file, this policy has no nonce and no `'unsafe-inline'`. If you already have inline scripts elsewhere in the project, move them to static files or add a nonce for them, rather than loosening the policy for the whole site. The [CSP guide for widgets](/resources/content-security-policy-widgets) explains how to read a blocked request in the browser console.

## Send feedback from a Django view

When feedback starts on the server, for example from a contact form that a view handles, post it to the [REST API](/docs/api). Put this helper in `myproject/feedback.py`. The standard library is enough for one request:

```python
import json
import urllib.request

ENDPOINT = "https://escutaproduto.com/api/v1/feedback"

def send_feedback(message, kind="other", email=None):
    payload = {"key": "pk_your_product_key", "kind": kind, "message": message}
    if email:
        payload["email"] = email

    request = urllib.request.Request(
        ENDPOINT,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=5) as response:
        return json.load(response)
```

The API returns 201 with an id on success. It returns 400 for invalid data, 413 for a body over 16 KB and 429 above 10 requests per minute per IP and product. `urlopen` raises an error for those responses, so catch `urllib.error.HTTPError` in the view and show a retry message on 429. Server requests carry no Origin header, so your allowed origins list does not apply.

## Keep the widget working with htmx partials

If your project uses htmx, partial swaps replace fragments of the page and leave the `head` alone. The widget script and the identify file were loaded with the page, so they keep running after a swap. Put the `json_script` element in the base template, as shown above, rather than inside a partial, so a swap that removes the element cannot change the identity the widget already holds.

## Open the form from a Django template

A button with `data-escuta-open` opens the form, with the type preselected:

```django
<button type="button" data-escuta-open="idea">Suggest a feature</button>
```

Valid types are `bug`, `idea`, `praise` and `other`. To hide the floating button and use only your own, add `data-trigger="none"` to the widget script tag in `base.html`. Templates that extend the base get the attribute behavior without any extra script.

## Check the Django install in your Escuta Produto inbox

1. Add `http://localhost:8000` (the default Django dev server URL) to the allowed origins of your product.
2. Sign in, open the Feedback button and send a test idea from a page that uses the base template.
3. In the inbox, confirm the item shows your name and email, plus the page URL.
4. Sign out, reload the page and send another item. That item should have no name, which confirms the signed-out path.

If the name is missing, check the page source for the `escuta-user` element. If it is absent, the context processor returned None, which usually means the setting was not registered.

For the complete option list, read the [widget reference](/docs/widget). The [Rails guide](/resources/feedback-widget-rails) covers the same layout pattern in Ruby, and the [Laravel guide](/resources/feedback-widget-laravel) covers it in PHP.

## Frequently asked questions

### Where do I add the feedback widget script in a Django project?

In the head of your base template, usually templates/base.html, with the defer attribute. Every template that extends the base gets the script, so the Feedback button appears on each page.

### How do I pass the logged-in user to the widget in Django?

Return a dictionary with the id, email and name from a context processor, then render it with the json_script filter. The filter escapes the JSON for a script tag, and a small static file reads it and calls identify.

### Why not put request.user directly into a script block in Django?

A user object cannot be converted to JSON, and writing values straight into a script block risks breaking out of it. Build a plain dictionary with only the fields you need, and let json_script handle the escaping.

### Which django-csp settings does the feedback widget need?

Add the widget host to script-src and connect-src in your CONTENT_SECURITY_POLICY setting, using django-csp 4.0 or later. Because the identify code lives in a static file, you do not need a nonce for it.
