Drop-in widget without leaking secrets.
The fastest way to ship Trooply to a storefront is a one-line embed. The fastest way to ship a security incident is to put a sk_live_ server secret inside that embed. Both happen more often than they should. Public-key auth is the fix.
The footgun we wanted to close
Every search SaaS you've integrated probably had a version of this snippet in its "get started" docs:
<script src=".../search-widget.js"
data-client-id="client_abc123"
data-client-secret="sk_live_..."
data-api-base="https://search.trooply.ai">
</script>
That sk_live_ is the same credential your server uses to index products, delete SKUs, and read analytics. It's in your storefront's HTML, which means it's in Google's cache, in every shopper's browser history, and one View-Source menu away from being exfiltrated. We've seen it posted to GitHub, pinned on Discord help forums, included in "how I built this" blog posts.
This has been an industry-wide problem because there was no better option — widgets need some credential, and rotating a production secret is disruptive. Our answer is a second, narrower key type.
The shape of a public key
A Trooply public key looks like pk_live_54f9…. Three properties make it safe to embed:
- Read-only surface. Public keys only unlock the search endpoints —
/v1/widget/search/text,/v1/widget/search/url,/v1/widget/search/upload(multipart, since widget v1.3.0),/v1/widget/search/autocomplete, and/v1/widget/search/frequently-bought-with/{id}. Indexing, deletion, merchandising, analytics — all refuse the key. If someone copies yours, the worst they can do is search your catalog, which they could already do by visiting your website. - Origin allow-list. Each key has up to 50 origins it's allowed to be presented from. The API checks the browser's
Originheader (falling back toReferer) on every request. A key lifted from your site and pasted ontoevilshop.comreturns 403 on the first call. - Per-key rate limit. Each public key has its own rate budget. A key that starts getting hammered — say, because it leaked into a scraper — won't take down the shared limit on your account.
Minting a key
The portal path is /portal/widget → "Public keys" → "Create key". Give it a name, list the origins that should accept it, save.
The API path is one POST:
POST /v1/widget-keys
{
"name": "Shop frontend",
"allowed_origins": ["https://shop.example.com", "https://www.shop.example.com"]
}
Response returns the key value once — store it in your build pipeline or paste it directly into the storefront. Unlike server secrets, there's nothing to regret publishing this; the origin binding does the heavy lifting.
Swap the embed snippet
Replace data-client-secret with data-public-key. That's it.
<script src="https://search.trooply.ai/widget/v1/search-widget.js"
data-client-id="client_abc123"
data-public-key="pk_live_54f9..."
data-api-base="https://search.trooply.ai">
</script>
The widget detects the presence of data-public-key and switches to the narrower auth path. Image uploads still work — as of widget v1.3.0 the file is posted directly as multipart to /v1/widget/search/upload, with X-Trooply-Key doing the auth. Earlier widget builds did the same thing via a base64 data URL through /v1/widget/search/url; that path still accepts public URLs if your storefront already hosts the image.
If you leave the old data-client-secret attribute in place, the widget still works but logs a loud console warning:
[Trooply] data-client-secret is deprecated and exposes your sk_live_ key
to anyone viewing the page source. Create a public key at /portal/widget
and switch to data-public-key instead.
What happens on each request
When the widget makes a search call, three headers matter:
X-Trooply-Key: pk_live_...— the key itself.Origin: https://shop.example.com— set automatically by every modern browser.Content-Type: application/json— for the body.
Our middleware validates three things in order: the key exists and is active, the request's Origin is in the key's allow-list, and the per-key rate budget isn't exhausted. Any failure returns 403 without leaking which check failed — we don't want to help an attacker enumerate origins.
Some browsers omit the Origin header on same-origin GETs. We fall back to Referer parsing for that case, and we pin the comparison to the origin portion only — no query-string or path comparison. If a request has neither header, it's rejected. That's stricter than "allow if absent", because an absent Origin is exactly what a scraper sends.
Operational details
- Deactivate, don't delete. If you suspect a key leaked, toggle
is_activeto false in the portal. The key stops working within seconds. If the leak turns out to be a false alarm, re-enable it and no URLs need to change. - Origin list is exact.
https://shop.example.comandhttps://www.shop.example.comare two different origins. So ishttp://vshttps://. List every permutation you actually use. - 50-origin cap per key. If you have more than 50 domains, mint multiple keys — one per brand, one per region, whatever the cohort is.
- No CORS headaches. The widget endpoints reply with
Access-Control-Allow-Originmatching the request's validated origin. You don't need to configure anything else on your side.
Migrating an existing storefront
The safest upgrade path:
- Mint the public key in the portal with the correct origins.
- Ship the embed-snippet change in a normal deploy. Both attributes present is fine — the widget prefers the public key when it sees one.
- After a day or two of healthy traffic, remove
data-client-secretfrom the snippet. - Then rotate the server secret via /portal/developer. This invalidates anything that was previously using it, so you want to do it only after you're sure no page still references it.
Wiring purchase events back to search
Clicks and add-to-cart events fire automatically from the widget. Purchases don't, because the checkout-success page is usually a server-rendered route the widget never sees. Call the public global from that page once per purchased SKU so revenue gets attributed back to the search session that surfaced the product:
// Order-confirmation page, after the cart has been written.
order.lines.forEach(line => {
window.TrooplyWidget.trackPurchase(line.sku, {
order_value: line.line_total, // line subtotal in the order's currency
currency: order.currency, // ISO-4217 — 'USD', 'EUR', 'GBP', …
quantity: line.quantity,
});
});
The widget keeps the shopper's session_id in localStorage (trooply-sid) for the entire visit, so even though the search page and the order-confirmation page are different routes, the events join up. When this is wired, the Conversion by search mode panel on /portal/analytics starts breaking out revenue by retrieval surface — text vs image vs upload — within minutes of the first attributed purchase.
If you'd rather build your own tracking layer (custom analytics, server-side firing, etc.), POST /v1/widget/search/feedback takes the same body schema as /v1/search/feedback but with X-Trooply-Key + Origin instead of a Bearer token. Pass search_id from the original search response to link the feedback to a specific query.
Next post
Once the widget is live and shoppers are searching, the inputs to the model matter — bad catalog photos produce bad similarity scores, and no amount of merchandising fixes that. The photo-quality audit is how we tell merchants which SKUs to re-shoot.