DEV Community

Nazma Akter
Nazma Akter

Posted on

Social proof popups that can't lie: how we built ProofLoom's widget

"Someone in London just bought this!" popups have a bad reputation, and they earned it. Plenty of tools will happily show made-up purchases, or let you type your own fake ones.

When we built campaigns for VeloxonHub ProofLoom, we set one rule: the widget can only show something that really happened. It shouldn't depend on the merchant's good intentions, either. It has to be impossible by design.

Here's how the code enforces that, plus a few things we learned putting a script on other people's websites.

1. Activity comes from real events, or nothing shows

A "recent activity" notification is built from the site's display events: purchases, sign-ups and bookings that came in through a connected source (a store, a payment provider or a booking tool). Each event can expire and can be revoked, and the public feed only returns events that are neither.

The widget turns that feed into notifications. If the feed is empty, it shows nothing. There's no fallback that "fills in" activity, and there's no field in the editor to type one in. The editor is deliberately closed: campaigns are typed config with no free-form HTML, JavaScript or CSS, and the API rejects unknown fields, so a typo can't silently turn into something else.

Two smaller honesty details:

  • The relative time ("2 minutes ago") is computed from the event's real timestamp.
  • If an event has no city, "{actor} from {city}" collapses cleanly instead of reading "Someone from".

2. Minimums are enforced on the server, not in the browser

"35 people purchased in the last 24 hours" is only persuasive when the number is real and big enough. Merchants set a minimum, and below it nothing shows.

The important part is where that check runs. If the API sent the real count and let the widget decide, anyone could open DevTools and see "2 purchases". So the Go service counts the events in the window and adds the number to the public feed only when it meets the minimum. Below that, the widget never receives a count at all.

Live visitors work the same way. The widget sends a heartbeat only while the tab is visible, so background tabs don't count, and all tabs of one visit share an id, so a shopper with five tabs open counts once. A visitor counts as "here now" for 90 seconds after their last heartbeat, and the count is only published above the merchant's minimum.

Testimonials appear only when the customer allowed website use. Announcements, the merchant's own offers, are never formatted as customer activity.

3. Living on someone else's page

The widget runs on stores we don't control, so a few rules made it safe on both sides:

  • Closed Shadow DOM. The merchant's CSS can't break the notification, and the notification's CSS can't leak into the store.
  • Text, never HTML. Every placeholder is filled with textContent, and links and images are https-only (they're also sanitized on the server).
  • No CORS preflight. Collection calls send text/plain bodies, so they stay "simple" CORS requests and add no extra round trip.
  • Silent failure. If our API is down or the site isn't verified, the widget stays quiet. The merchant's page never waits on us or breaks because of us.
  • Timers pause while the tab is hidden, so a notification is never counted as "shown" when nobody could see it.

4. Versions: publish is a snapshot, rollback is a pointer

The editor saves a draft config. Publishing runs in one transaction that checks the plan, that the site is verified, that a source is connected (for activity campaigns) and the active-campaign limit. It then copies the draft into a new immutable version and points the campaign at it.

The widget only ever reads the active version. That makes rollback trivial: "Restore v1" just moves the pointer back, and you can keep editing the draft without touching what's live.

5. A/B tests that don't flicker

On the Business plan, a campaign can have a variant B with its own text and design. Each browser gets a random seed in localStorage, and the arm is:

fnv1a(seed + ":" + campaign.id) % 100 < split_b ? "b" : "a"
Enter fullscreen mode Exit fullscreen mode

So a visitor always sees the same variant of a campaign, across pages and visits, with no server round trip. If storage is blocked, the visitor sees A and is left out of the test rather than being counted in a random arm. The API also rejects a variant B that's identical to A, since it would compare nothing.

6. Privacy defaults

  • The browser's session id is stored only as a site-scoped hash, so the same browser can't be linked across two sites.
  • For audience targeting, the widget keeps only the visit's UTM source and referrer host, never the full URL.
  • data-consent="required" makes the widget wait for ProofLoom.consent(true) from the site's consent banner before it tracks or shows anything.

What we'd tell you if you're building something similar

  • Put honesty checks where the user can't reach them. A minimum enforced in client-side JavaScript isn't a minimum.
  • An empty state is a feature. "Show nothing" is a better default than any fallback.
  • Make the editor closed. Typed fields are less flexible than an HTML box, and that's the point.
  • Assume your script is a guest. Use a closed Shadow DOM, plain text and silent failure.

Try it

Campaigns install with one script on WordPress, Shopify or any site, and there's a 14-day free trial.

I'm happy to answer questions about the feed, the presence counting or the A/B assignment in the comments.

Top comments (0)