Headless storefronts
Running Evident on a custom React, Next.js, Vue or Astro storefront — no theme, no platform connection required.
9 min read Last updated
On this page
Evident does not need to live inside a theme. If your storefront is your own code, you push your catalogue and orders over the API and render with the same widget SDK everyone else uses.
There is no sandbox: everything below runs against your real store. Environments & safe testing covers how to build against it without emailing a customer, and Troubleshooting the widgets is where to go when a widget loads and shows nothing.
1. Get your store environment ID
Settings → Store. Copy the store environment ID.
This is the only credential the storefront needs. There is no separate storefront token to request and nothing secret to keep on a server — it identifies which store a request belongs to. What controls who may call the API is the origin allow-list in the next step.
2. Allow-list the origins you render from
Still under Settings → Store, add every origin your storefront runs on to Allowed Storefront Origins — production, staging, and http://localhost:3000 for local development. Changes take effect immediately.
https://yourstore.com
https://staging.yourstore.com
http://localhost:3000
An origin is scheme, host and port — no paths, no trailing slash. A bare domain is treated as https. The www. and bare forms of a domain count as one, so listing https://yourstore.com also covers https://www.yourstore.com; any other subdomain needs its own entry.
Two things trip people up here:
- Preview deployments. Every Vercel or Netlify preview URL is a different origin and is blocked until listed.
*is not a wildcard. Wildcards are rejected when you save. There is no pattern syntax.
And a debugging note that will save you an hour: curl cannot reproduce a CORS failure. It sends no Origin header, so the request succeeds and tells you nothing. Reproduce in a browser.
3. Load the SDK
Add the script once, in your app shell or root layout. It reads your store environment ID from the data attribute, pulls in its own stylesheet, and mounts every widget container it finds — including ones your framework renders later.
<script
src="https://app.evidentugc.com/widgets/evident-sdk.min.js"
data-store-env-id="YOUR_STORE_ENV_ID"
defer
></script>
4. Place a widget container
Widgets mount into any element carrying data-evident-widget. A gallery needs only its slug, which you will find under Galleries:
<div
data-evident-widget="gallery"
data-gallery-slug="customer-photos"
data-columns="3"
></div>
Galleries, FAQs and store-wide review widgets work immediately. Product-scoped widgets — star badges, per-product review lists, review forms — need your catalogue in Evident first. That is step 6.
5. React, Next.js, Vue and other SPAs
The SDK watches the DOM, so a container your framework renders after page load is mounted automatically, and re-mounted if a re-render wipes it.
If you would rather mount explicitly:
'use client';
import { useEffect, useRef } from 'react';
export function EvidentGallery({ slug }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
window.Evident?.mount(el);
return () => window.Evident?.unmount(el);
}, [slug]);
return (
<div
ref={ref}
data-evident-widget="gallery"
data-gallery-slug={slug}
/>
);
}
window.Evident may not exist yet on first paint, which is why the optional chaining matters — the container is still picked up when the script finishes loading.
The full API: refresh() re-scans and mounts anything blank, mount(el) mounts one container, unmount(el) tears one down, and init() is safe to call more than once — React StrictMode double-invokes effects in development.
The widget renders into a wrapper element it owns, rather than into your node directly, so a React re-render cannot blank it.
The same two calls work from any framework. In Vue:
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
defineProps({ slug: String });
const el = ref(null);
onMounted(() => window.Evident?.mount(el.value));
onBeforeUnmount(() => window.Evident?.unmount(el.value));
</script>
<template>
<div ref="el" data-evident-widget="gallery" :data-gallery-slug="slug" />
</template>
mount returns false and does nothing if the DOM watcher got there first, so calling it is always safe.
Server-rendered pages — Astro, or anything else that sends the containers in the HTML — need nothing extra. If you add client-side navigation and a widget does not appear after a route change, call window.Evident?.refresh() from your router’s navigation hook (in Astro, the astro:page-load event).
6. Push your catalogue
Platform stores sync their catalogue automatically. A headless store pushes it. Create an API key under Settings → API Keys with the Admin role — the default, Viewer, is read-only, and Member does not include writing the catalogue or orders — and, under Access, give it read & write on products and orders. Then upsert products — up to 250 per call, keyed on your own product ID, so re-sending the same payload is safe.
curl -X PUT https://api.evidentugc.com/api/v1/products \
-H "Authorization: Bearer evnt_YOUR_API_KEY" \
-H "x-store-env-id: YOUR_STORE_ENV_ID" \
-H "Content-Type: application/json" \
-d '{
"products": [
{
"platformProductId": "sku-1024",
"name": "Merino Wool Runner",
"sku": "MWR-BLK-10",
"gtin": "0012345678905",
"mpn": "MWR-2024",
"brand": "Allbirds",
"imageUrl": "https://cdn.example.com/mwr.jpg",
"productUrl": "https://example.com/p/merino-wool-runner",
"price": 98.00
}
]
}'
This is a replace, not a merge. A field you omit is cleared, so send the whole product each time.
The payload is validated as a whole first: a row with a missing name, a malformed URL or a field Evident does not recognise rejects the entire request with a 400 naming the problem, and nothing is written. Once a request passes validation, rows are written independently — one that fails to save does not stop the others — so check the failed count in the response rather than the status code alone.
The platformProductId you send here is the value your widgets pass as data-product-id.
Send the trade identifiers if you have them
sku, gtin, mpn and brand are all optional, and nothing in Evident requires them. They exist for feeds that have to match your reviews against your catalogue somewhere else — Google Merchant Center pairs a review to a product on GTIN first, then brand and MPN or SKU, and only then the product URL.
A platform-connected store gets these from the platform. A headless store only has what you send, and because this endpoint replaces rather than merges, omitting them on a later push clears them. If you have a GTIN, send it on every push: it is the one identifier that matches reliably.
7. Push your orders
Orders are what connect a reviewer to a purchase. Push them and you get the verified-purchase badge plus automated review requests.
Push your catalogue first — line items link to products by platformProductId, and verified purchase matches on that link, so an order whose products are missing is stored but inert. The response’s unlinkedLineItems tells you how many line items are still unlinked — across your whole store, not just the batch you sent — so it should fall to zero once your catalogue is complete.
curl -X PUT https://api.evidentugc.com/api/v1/orders \
-H "Authorization: Bearer evnt_YOUR_API_KEY" \
-H "x-store-env-id: YOUR_STORE_ENV_ID" \
-H "Content-Type: application/json" \
-d '{
"scheduleReviewRequests": false,
"orders": [
{
"platformOrderId": "order-5581",
"customerEmail": "[email protected]",
"customerFirstName": "Dana",
"status": "Shipped",
"orderDate": "2026-08-01T14:22:00.000Z",
"lineItems": [
{ "platformProductId": "sku-1024", "quantity": 2, "price": 98.00 }
]
}
]
}'
Things worth knowing before you go live
Review requests are opt-in, and only for recent orders. Pushing an order never emails anyone unless you set scheduleReviewRequests. Even then, orders older than 30 days are skipped: the send delay is counted from when you push, not from the order date, so a backfilled order would otherwise be treated as if it shipped today.
Turn it on only once you are pushing orders as they happen. Two store settings also have to agree:
- Review request emails must be switched on. They are off for a new store: Settings → Store → Review Settings → Configure, then Enable review request emails. Until then
scheduleReviewRequests: trueschedules nothing. - The status you send decides eligibility. Requests trigger on
Shippedby default, matched without regard to case. The status picker in that same dialog lists statuses read from a connected platform, so on a headless store it stays at the default — sendShippedfor orders that should get a request.
The same flag also decides whether a pushed order fires the order.placed and order.completed outbound webhooks, so a backfill does not flood your subscribers either.
Content Security Policy. If you enforce one, the widgets need these sources:
script-src https://app.evidentugc.com
style-src https://app.evidentugc.com 'unsafe-inline'
connect-src https://api.evidentugc.com https://upload.cloudflarestream.com
img-src https://cdn.evidentugc.com https://*.cloudflarestream.com blob: <your product image host>
media-src blob:
frame-src https://*.cloudflarestream.com
connect-src is the one most often missed. The SDK fetches every review, rating and FAQ from api.evidentugc.com, so without it the widget loads and then shows nothing — or, for a review list, “Failed to load reviews” — and the only clue is a blocked request in the browser console.
The rest, line by line:
'unsafe-inline'instyle-srcis for the colours and fonts you set under Storefront → Install & Theme, which the SDK writes into a<style>element. Without it the widgets still work, in their default styling.- The Cloudflare Stream entries are for video reviews: playback is an embedded player, and the review form uploads straight to Stream. If you do not accept video, you can leave them out.
blob:lets the review form preview a photo or video before it is uploaded.- Product images are the URLs you pushed in your catalogue, so allow whichever host serves them.
Recording a video in the browser also needs camera and microphone access. If you send a Permissions-Policy header, it must allow both on the pages that carry the review form.
Roll a new policy out as report-only first. A report-only policy logs violations without blocking — the widget will stop rendering the moment you enforce one that is missing a source.
Script tag, not a package. The SDK ships as a script tag rather than an npm package today. It is framework-agnostic and needs no build step, but there is no typed React component to import — wrap it yourself as in step 5.
Loyalty accrual works on pushed orders, provided the status you send is one Evident counts. Points are derived from the order row itself rather than from a platform event, so a pushed order earns exactly like a synced one — but only if its status is Completed, Shipped or Awaiting Fulfillment, and only once loyalty has an orders start date set. See Earning rules.
Something missing or out of date? Email [email protected] — docs corrections go straight to the team that builds the feature.