Add a feedback widget to a Flask app
Add the widget script to the head of your Jinja base template with defer. Pass the signed-in user from a context processor, render it with the tojson filter inside a JSON script block, and call identify from a static file. Keep the product key in environment config, and allow the widget host in your Content Security Policy.
By Rafael Thayto 路 Published 路 Last updated
Where does the script tag go in a Flask base template?
Most Flask apps keep one Jinja layout that every page extends, usually templates/base.html. Put the widget script in its head with the defer attribute. The browser downloads it in parallel with the page and runs it after the HTML is parsed, so it never blocks the first paint. Every template that extends the base then shows the Feedback button:
<!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>
{% if config.ESCUTA_KEY %}
<script src="https://escutaproduto.com/widget.js" data-key="{{ config.ESCUTA_KEY }}" defer></script>
<script src="{{ url_for('static', filename='escuta-identify.js') }}" defer></script>
{% endif %}
</head>
<body>
{% if escuta_user %}
<script id="escuta-user" type="application/json">{{ escuta_user|tojson }}</script>
{% endif %}
{% block content %}{% endblock %}
</body>
</html>
The if config.ESCUTA_KEY guard means a local run without a key renders no widget at all, instead of a broken button. The data-key is your public product key, which can only create feedback. The second script is your own identify file, served from static/. Both scripts load with defer, so the widget code runs first and then your identify code reads the queue.
How do I pass the logged-in user without breaking the page?
Add a context processor so the user is available to every template without repeating code in each view. This example uses Flask-Login, but any session helper works the same way. Put it in your application factory or in app/context.py:
from flask import Blueprint
from flask_login import current_user
bp = Blueprint("escuta", __name__)
@bp.app_context_processor
def inject_escuta_user():
if not current_user.is_authenticated:
return {"escuta_user": None}
return {
"escuta_user": {
"id": str(current_user.id),
"email": current_user.email,
"name": current_user.name,
}
}
Register the blueprint with app.register_blueprint(bp). The dictionary holds only the three fields the widget uses. Converting the id to a string keeps the type the widget expects. Passing the whole user object would leak every column into the page, so build the dictionary by hand.
The app_context_processor decorator registers the function for the whole application, even though it sits on a blueprint. That means templates rendered by any other blueprint also receive escuta_user.
Why is tojson safe inside a script block?
Writing {{ escuta_user }} into a script would print Python's repr, which is not valid JSON. Writing it as a plain JSON string risks a name that contains </script> closing your tag early. The tojson filter solves both problems. It serializes the value as JSON and replaces the characters <, >, & and ' with Unicode escapes, so a name such as </script><script> stays as harmless text.
The type="application/json" on the script element tells the browser not to run it. The element only carries data. Your identify file reads its text content, parses it and passes the object to the widget. Here is that file, saved as static/escuta-identify.js:
(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]]);
})();
A static file keeps all the code outside the template, so your Content Security Policy does not need 'unsafe-inline' or a nonce for it. Avoid writing {{ current_user.email }} inside a <script> tag, because the default HTML escaping produces entities that JavaScript does not decode.
Which Flask config keeps development and production keys apart?
Read the key from the environment, so each deploy uses its own product. Create one Escuta Produto product for production and another for local work. Put the key in config.py:
import os
class Config:
ESCUTA_KEY = os.environ.get("ESCUTA_KEY", "")
Set ESCUTA_KEY in your hosting environment for production, and in a local .env file for development. If you load .env with python-dotenv, call load_dotenv() before the app reads its config. The key is public, so it can sit in a template, but keeping production and development keys separate means a local test never lands in your live inbox.
Each product has its own allowed origins. Add http://127.0.0.1:5000 to the development product, which is where flask run serves by default, and add your production domain to the production product. A request from an origin that is not on the list gets a 403, so a copied key cannot send feedback from another site.
How do I set a Content Security Policy with Flask-Talisman?
Flask does not send a Content Security Policy by default. The Flask-Talisman extension adds the common security headers and takes a policy dictionary. The widget needs its host in script-src and connect-src:
from flask_talisman import Talisman
csp = {
"default-src": "'self'",
"script-src": ["'self'", "https://escutaproduto.com"],
"connect-src": ["'self'", "https://escutaproduto.com"],
"style-src": ["'self'", "'unsafe-inline'"],
}
Talisman(app, content_security_policy=csp, force_https=not app.debug)
Because the identify code is in a static file, this policy needs no inline script exception. Turn off force_https in development, otherwise the local server redirects to a secure address it cannot serve. If a template still contains an inline script, move it into a static file before you loosen the policy. The CSP guide for widgets explains how to read a blocked request in the browser console.
How do I send feedback from a Flask route?
When feedback starts on the server, for example from a support form, post it to the REST API. The requests library is enough for one call. Validate the message first, then send it with a short timeout:
import requests
from flask import flash, redirect, request, url_for
@bp.route("/feedback", methods=["POST"])
def feedback():
message = request.form.get("message", "").strip()
if len(message) < 2:
flash("Please write a little more.")
return redirect(url_for("main.index"))
response = requests.post(
"https://escutaproduto.com/api/v1/feedback",
json={"key": "pk_your_product_key", "kind": "other", "message": message},
timeout=5,
)
if response.status_code == 429:
flash("Too many messages right now. Please try again in a minute.")
else:
flash("Thanks, we received your message.")
return redirect(url_for("main.index"))
The API returns 201 with an id on success, 400 for invalid data and 413 for a body over 16 KB. It also returns 429 above 10 requests per minute per IP and product. Server requests carry no Origin header, so the allowed origins list does not apply to them. For the full request shape, see the API reference.
How do I open the form from my own button?
To place your own button, add data-trigger="none" to the widget.js script tag in base.html. That hides the floating button, and the widget no longer shows its own entry point. Any element with a data-escuta-open attribute opens the form, and the value preselects the type. Valid values are bug, idea, praise and other:
<button type="button" data-escuta-open="idea">Suggest a feature</button>
Place the button in any template that extends the base layout. You can keep the floating button too by leaving out the attribute. The widget reference lists the complete set of options.
What do I check when no items reach the inbox?
Check the easy things first. Open the page source and confirm that the widget.js script tag and the escuta-user element are present. If the script tag is missing, ESCUTA_KEY is empty in that process, so check your environment file and restart flask run. If the Feedback button appears but sending fails, the browser console will show a 403 for an origin that is not allowed. Add the exact address, including the port.
If the name is missing from an item, the context processor returned None, which usually means the user was not loaded in that request. Make sure the blueprint or the factory that registers the processor runs before your routes. The widget reference lists the remaining options, and the best practices guide covers where a feedback button works best on a page.
Sending a first test from Flask to Escuta Produto
Start the server with flask run, sign in and open the Feedback button from a page that extends the base template. Send one bug and one idea. In the inbox, the bug should show your name, email and page address. Sign out, reload and send a third item to confirm the anonymous path.
The Flask guide follows the same pattern as the other server frameworks in this series. If your stack is Django, read the Django guide. For a Node project with a React front end, the React guide covers identify from an auth context. Escuta Produto keeps every product in one inbox, so a Flask service and a mobile app can share a single list of feedback.
Frequently asked questions
Where should the feedback widget script go in a Flask app?
In the head of your Jinja 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 Flask?
Use a context processor that returns a dictionary with the id, email and name. Render it with the tojson filter inside a script element of type application/json, then let a static file read it and call identify.
Is the Jinja tojson filter safe to use inside a script tag?
Yes. The filter escapes the angle brackets, ampersands and single quotes in the JSON, so a value cannot close the script element. Pair it with type application/json so the browser treats the block as data.
How do I keep separate feedback keys for development and production in Flask?
Read the product key from an environment variable in your config class. Create one product for production and another for local work, each with its own allowed origins, so local test items never reach the live inbox.
Related
- Add a feedback widget to a React appInstall the Escuta Produto feedback widget in a Vite React app: the script in index.html, identify from your auth context, and how routes behave.
- Add a feedback widget to a Vue appAdd the Escuta Produto feedback widget to a Vue 3 app with Vite: script in index.html, identify in a composable after login, and Vue Router notes.
- Add a feedback widget to a Nuxt appAdd the Escuta Produto feedback widget to a Nuxt app: app.head script config, a client-only plugin for identify, and server rendering caveats.
- Add a feedback widget to a SvelteKit appAdd the Escuta Produto feedback widget to a SvelteKit app: the app.html script, identify from layout data, and Content Security Policy in svelte.config.js.