Ask customers for a public review
Exit interview + review request — ask everyone, route by platform
For when the answer to “Ask finished customers for a public review (Google, Facebook, BBB)” is yes.
The step after a job is finished: a short interview, then the customer's OWN words offered back to them to post publicly.
Skip the rebuild
You do not write this one. It arrives in your project as ordinary source you can read, change and keep, with its audit record and its caveats beside it.
- Download the Playground. It is free and runs on your own machine.
- Start a new app and tick “Ask finished customers for a public review (Google, Facebook, BBB)”.
- This block is written in, along with anything else you ticked.
The mistake it removes
Two failure modes, both structural. (1) REVIEW GATING: offering the public-posting step only to customers who scored well. Google prohibits it, can remove the reviews and penalize the listing, and the FTC's 2024 rule on suppressed reviews raises the exposure. (2) ONE FLOW FOR EVERY DESTINATION: Yelp asks businesses not to solicit reviews AT ALL, so a Yelp button sitting in the same row as the Google one puts the customer in breach while showing them a screen that looks fine.
What is already handled
Each of these was checked by running the code, not by reading it.
- REVIEW GATING, pinned per rating value: the public-posting destination set is byte-identical at every rating 1..5, and only the wording differs — asserted for the widget's own render path, not merely for the helper it calls
- the widget computes destinations from the CONFIG ONLY: renderOutcome passes no answers and no rating into classify(), asserted structurally so a future edit that threads the rating in fails the check
- Yelp, whose terms prohibit soliciting reviews, is withheld from the ask unless the business owner sets acknowledged:true — and acknowledging it buys VISIBILITY, never the one-click path: the widget renders no button that opens it, its URL is absent from the DOM until the customer reveals it, revealing it opens nothing and copies nothing, and the platform's own rule is shown before any of it. Asserted in Node (review-gating-check.mjs, including that window.open is never called) and in a real browser (test/browser.html scenario C)
- a destination id with no policy on file resolves to solicit:'unknown' and is withheld, so an unrecognized platform never inherits Google's permission
- provenance is MEASURED, not asserted: all 13 files this block ships whose names the extracted-from source also carries are hashed against that source on every run, and block.json's per-file table (provenanceFiles) must declare the relation the bytes actually have — one still byte-identical, twelve not, including the ONE deliberate divergence in each rules file (the configurable honeypot name). The sentence a customer reads is generated from that measurement, and any other sentence in the provenance that claims the opposite about a file it names fails the check — the control it replaced grepped for one retired wording and passed a reworded false claim
- the builder's validateDefinition refuses a duplicate id, a duplicate role, a one-option choice, a switched-on destination with no https link, and an unacknowledged restricted destination — each with a message naming which question
- review_refresh.py distinguishes never / fresh / stale / failing / unreadable, keeps the previous payload when a fetch fails but does NOT move last_success, and exits non-zero on anything but fresh
- the rate-limit key is NOT caller-controlled: with no trustProxy set, X-Forwarded-For is ignored entirely, and with one set only the right-most untrusted entry is used — driven against the real endpoint by rotating the header 12 times from one peer, in BOTH the Node and the PHP half
- a ceiling across every caller, driven at the shipped default (60/hour) from 80 distinct peers so the per-connection limit never fires
- the endpoint surface itself: 405 on GET, 400 on malformed JSON, 413 on an oversize body before it is parsed, the honeypot answering 200 while storing nothing, and a retry de-duplicating — asserted for both server halves
- the honeypot's NAME is configurable and works at a non-default value: with module.json naming it website_url, a legitimate interview is accepted and stored while a bot filling the trap is answered 200 and stored nowhere — asserted through both validators and both endpoints, plus the widget payload that has to use the same name
- the JavaScript and PHP validators agree on all 40 shared cases (counted here: test/cases.json#cases) — same verdict, same blamed field, and the same normalized answers, so the two halves cannot store a rating as a number in one and a string in the other
- retention: records older than 90 days are deleted, a record with an unreadable timestamp is KEPT rather than destroyed, one person can be erased on request, and a store that cannot delete fails loudly instead of reporting a deletion that never happened
What the audit found
Named rather than summarized. The reasoning behind each one ships inside the block, so it travels with the code instead of living on a page you have to trust.
- Packaged but not audited major · Deploy readiness
What you still have to do
A copied file cannot own your secrets, your host or your legal obligations. This is the part the block deliberately does not claim.
- Set trustProxy to the CIDR of the proxy that REALLY sits in front of the app, and put the builder panel behind admin-kit's requireAdmin — the panel rewrites the live question set and has no auth of its own.
- The heading order, color contrast and lang attribute of the page you embed the widget in. The widget ships labeled controls and a keyboard-reachable flow; the document around it is not something a block can author.
- Publish a privacy page stating the same retention the code enforces, and change RETENTION_DAYS and that page together. Submissions carry name, email, phone, the submitter's IP and any X-Forwarded-For it claimed — that is personal data, and a policy that disagrees with the code is the defect either way round.
- A rate limiter backed by a shared store or by your edge. Both the per-connection limit and the cross-caller ceiling here are in-memory and best-effort; on serverless they cover one warm instance.
- You supply the store, so its durability and its backups are yours. The block guarantees only that it writes through your put() and reports whether the record was stored.
- NOT YET DD-AUDITED AS A BLOCK. The code it was extracted from runs on a live site, but no DD audit has been run against this packaging — the DD record is UNGRADED rather than a rating nobody earned. Run /dd against it before shipping it to a customer.
- X-Forwarded-For is IGNORED unless you set `trustProxy` to the proxy actually in front of the app. That is deliberate and it is the safe default: a rate-limit key the caller can write is not a rate limit. If your app really is behind a CDN, set trustProxy or every caller shares one key — the proxy's.
- Submissions carry name, email, phone, the submitter's IP and — when the caller sent one — the X-Forwarded-For value it CLAIMED, kept as a claim rather than merged into the IP field. That is personal data — set a retention policy and disclose it in the privacy page seo-kit generates.
- review_refresh.py schedules on the machine it is installed on. A laptop that is asleep does not refresh; --status is how you find that out rather than assuming.
- The destination policy table records what these platforms' terms said when it was written. Platform terms change, and this file does not check them for you.
- Retention is 90 days and the privacy copy generated for this block says 90 days. THEY MUST BE CHANGED TOGETHER. The site this came from shipped RETENTION_DAYS = 0 under a policy promising two years, which made the policy a promise the code did not keep — in either direction that is the same defect.
- The Node retention sweep runs from the submission path, at most once a day, so an install needs no cron. A site that stops receiving submissions therefore stops sweeping; run retention.sweep() (or `php review-tools.php sweep`) from a schedule if that matters to you.
What lands in your project
vanilla
- vanilla/review-widget.js
- vanilla/review-widget.css
- vanilla/review-destinations.js
- vanilla/review-builder.js
- node/review-validate.js
- module.json
Load order matters: review-validate.js, then review-destinations.js, then review-widget.js — each throws by name if its dependency is missing rather than failing later. ReviewWidget.mount(el, config, { shadow: true, css }) renders the interview; ReviewBuilder.mount(el, config, { onSave }) is the Modules panel. `node/review-validate.js` is listed here on purpose: it is ONE rules file shared by both halves, it physically lives in node/ so the server entry can import it as a sibling in either layout, and install flattens by basename so it lands next to the widget anyway.
node
- node/review-request-api.js
- node/review-retention.js
- node/review-validate.js
- test/cases.json
createReviewRequestApi({ config, store, onStored, trustProxy, retentionDays }) returns { submit, retention }. It validates against the SAME module.json the page was built from, so a question you removed cannot be submitted and one you added is enforced with no code change. The rate-limit key is the SOCKET PEER — X-Forwarded-For is read only when the peer is in `trustProxy`, and then only the right-most untrusted entry. review-retention.js is the 90-day expiry, the owner's export and the erase-one-person path; it needs `remove(recordIds)` on the store and says so loudly rather than deleting nothing quietly. cases.json ships so the install can be re-checked against the block's own module.json. The honeypot field's name comes from module.json (`honeypot_name`, default `honeypot`) because the widget, both validators and both endpoints all have to agree on it; the `honeypotName` option is for a front end of your own, it defaults to the configured name, and the API refuses to start if the two disagree or if the name collides with a question id.
php
- php/review-settings.php
- php/review.php
- php/review-validate.php
- php/client-ip.php
- php/rate-limit.php
- php/review-tools.php
- module.json
- test/cases.json
- test/agree.php
The shared-hosting half — upload beside the site with module.json, edit review-config.php and nothing else. It decides the same things in the same order as the Node half, and test/cases.json is run through BOTH validators (test/agree.php is the PHP side of that) so the two cannot quietly drift: the first Node port of this block reinstated a spoofable rate-limit key that this PHP had already fixed and documented, and an agreement test is what makes that visible instead of merely present. review-settings.php is the ONE reader of review-config.php and both entry points require it first — there used to be two readers with different default sets (ph105tsk79). review-tools.php is export / erase-one-person / sweep / config and REFUSES to run over HTTP — a route handing back every stored name, email, phone and IP is the worst thing on the site if its auth is ever wrong. Requires PHP 8.0+.
python
- python/review_refresh.py
The scheduled half. `--status` says when the connector last SUCCEEDED and exits non-zero when it has not; `--install-schedule` prints a launchd plist. It never talks to a vendor itself — the connector is injected.
What you supply
- config — the parsed module.json — questions, rating, destinations, copy. The only file you normally edit, and the builder panel edits it for you.
- store — { all(): Promise<Record[]>, put(record): Promise<void> } — the block is storage-agnostic. OPTIONAL: add hasSubmission(id): Promise<boolean> and de-duplication uses it instead of all(). Without it, de-duplication scans every stored record on every submission, which is fine for hundreds and a full table scan per POST by the thousands; the source avoids this by dedupe-by-filename, which a storage-agnostic store cannot do.
- onStored — your delivery (email/CRM/Slack). A failed delivery does not fail the submission, and the result says whether the record was stored.
- trustProxy — CIDRs (or bare addresses) of proxies that REALLY sit in front of this app and overwrite X-Forwarded-For — a CDN, a load balancer. EMPTY BY DEFAULT, which ignores the header completely, because the rate-limit key must be one the caller cannot choose: the site this was extracted from proved that reading the header first let ten submissions carrying ten different values pass a limit of six. An entry here is a promise that the peer at that address cannot lie; an unparseable entry refuses to start rather than silently trusting nobody.
- endpoint — where the widget POSTs (module.json `endpoint`, default /api/review)
- cache — review_refresh.py's cache file — where last_success is kept
Licensed MIT. It is a starting point, not a finished product.
Get it
Download the Playground See the other blocks
Nothing here is locked. The files are yours, in your folder, under a permissive license.
Questions
- Is Ask customers for a public review audited?
- It was audited on 2026-09-10, and the verdict was not a clean pass.
- What do I still have to do myself?
- Set trustProxy to the CIDR of the proxy that REALLY sits in front of the app, and put the builder panel behind admin-kit's requireAdmin — the panel rewrites the live question set and has no auth of its own.
- How do I get this code?
- Download the Playground, start a new app, and tick “Ask finished customers for a public review (Google, Facebook, BBB)”. The block is written into your project as ordinary source you can read and edit.
All pre-built code blocks · Learn to build from zero · The coding guide