Step 1 — Print the fragment from your server
This is the step that matters for SEO. Your server requests the fragment and prints the response into your HTML, exactly as it comes.
saz-es.public.shopadvizor.com/saz-ssr-serverIt serves both the fragment/JSON endpoints and the optional browser bundle
(Step 2). Shopadvizor deploys and versions it — you don't host
it. It is not the French service's URL: each market is its own deployment, and the Spanish one
lives under a path (/saz-ssr-server) rather than on its own subdomain.
GET https://saz-es.public.shopadvizor.com/saz-ssr-server/v1/fragment?ean=4001724038818&lang=es&schema=full&name=Ristorante%20pizza%20piccola%20margherita
Authorization: Basic <your credential>
The response is a self-contained HTML fragment: the widget with its styles, plus a
<script type="application/ld+json"> block. Print it verbatim — do not parse it, do not re-wrap
it, do not sanitise it.
The credential
Shopadvizor gives you a credential, and you send it on every request as the Authorization
header. It is checked by Shopadvizor's own system, not by the rating service, which simply passes it
along.
It reaches you already encoded, ready to go after Basic : the header is
Authorization: Basic <your credential>, with the value exactly as you received it. Do not encode it
again: a re-encoded value is rejected, and the widget comes back empty.
Two things about it:
- It is a whole credential on its own. Keep it where you keep your other API secrets — environment variable or secret manager, never in your repository, never in anything the browser can read. Sending it from server-side code only, as every example below does, is what keeps that true.
- It may not be enforced yet. Some deployments accept requests without it during onboarding. Send
it from day one anyway: the day it is enforced, an integration that never sent it gets
401and the widget disappears from the page — silently, because the response body is empty on purpose so nothing broken is printed inside your HTML.
Where the credential is enforced, a missing header, or one that does not start with Basic , gets
401 with an empty body. Until then the same mistake goes unnoticed — the request is still
answered — and only shows the day enforcement starts, so check your header now. A credential that is
sent correctly but not accepted gives an empty 200 instead, like an outage. In either case check the header before suspecting anything else, and if the credential looks right, ask
Shopadvizor: they are the ones who issued it and the only ones who can tell you whether it is still
valid.
Examples
- PHP / WordPress
- Node / Next.js
- Nginx SSI
- Apache SSI
<?php
$ean = get_post_meta(get_the_ID(), '_ean', true);
$name = get_the_title();
$url = 'https://saz-es.public.shopadvizor.com/saz-ssr-server/v1/fragment?'
. http_build_query(['ean' => $ean, 'lang' => 'es', 'schema' => 'full', 'name' => $name]);
$response = wp_remote_get($url, [
'timeout' => 5,
// SHOPADVIZOR_CREDENTIAL comes from wp-config.php or your secret manager —
// never from a file you commit, and never from anything the browser sees.
// The credential is given to you already encoded: send it after "Basic ", as it is.
'headers' => ['Authorization' => 'Basic ' . SHOPADVIZOR_CREDENTIAL],
]);
if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
echo wp_remote_retrieve_body($response); // print as-is
}
?>
async function ProductRating({ ean, name }: { ean: string; name?: string }) {
const params = new URLSearchParams({ ean, lang: 'es', schema: 'full' })
if (name) params.set('name', name)
const res = await fetch(`https://saz-es.public.shopadvizor.com/saz-ssr-server/v1/fragment?${params}`, {
// Server component: the header never reaches the browser. Keep it that way —
// do NOT move this fetch into a client component or a route the browser calls.
// The credential is given to you already encoded: send it after "Basic ", as it is.
headers: { Authorization: `Basic ${process.env.SHOPADVIZOR_CREDENTIAL}` },
// Short on purpose: an empty response (service unavailable) must not stay cached for long.
next: { revalidate: 300 },
})
const html = res.ok ? await res.text() : ''
if (!html) return null
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
# Only the fragment, and only for SSI includes: "internal" answers 404 to any
# visitor who requests this path, so nobody can use your credential through it.
location = /shopadvizor/fragment {
internal;
proxy_pass https://saz-es.public.shopadvizor.com/saz-ssr-server/v1/fragment;
proxy_ssl_server_name on;
# A one-line file outside your repository, readable only by nginx:
# proxy_set_header Authorization "Basic <your credential>";
include /etc/nginx/secrets/shopadvizor-credential.conf;
}
# Needs mod_proxy, mod_proxy_http, mod_headers and "SSLProxyEngine on" in the vhost.
# Only the fragment, and only for SSI includes: a visitor requesting this path gets 403,
# so nobody can use your credential through it.
<Location "/shopadvizor/fragment">
Require expr "%{IS_SUBREQ} == 'true'"
ProxyPass "https://saz-es.public.shopadvizor.com/saz-ssr-server/v1/fragment"
# Read from the environment Apache starts with, never written in a file you commit.
RequestHeader set Authorization "Basic ${SHOPADVIZOR_CREDENTIAL}"
</Location>
- Load
hydrate.json pages that use this component. On the first page load the browser reads the fragment's HTML and the widget shows. After a navigation inside your app, React inserts it withinnerHTML, which does not read Declarative Shadow DOM, so withouthydrate.jsthe widget stays blank. With it, the widget draws itself from the data embedded in the fragment. - Do not keep an empty response for long. During an outage the service answers
200with an empty body, and Next.js caches it like any other. That is why the example uses a shortrevalidate(5 minutes) and prints nothing when the body is empty: a longer value would keep the widget missing for that long after the service is back.
Any other server-side template: fetch the URL from your server with the Authorization header,
print the body into the page. That is all there is to it. Django, Rails, Laravel — the mechanism is
the same.
Nginx / Apache SSI is the exception. An SSI directive in a template cannot add a header, so the
header goes in the server configuration instead, on the location that proxies to the rating service.
Then your SSI include points at your own path (/shopadvizor/fragment?ean=…&lang=es) and the
credential is added on the way out.
The proxy forwards only /fragment, and only for SSI includes: a visitor who requests that path
from a browser is refused, so nobody can use your credential through your site. Keep the credential
itself out of any configuration file you commit, as the examples show.
There is deliberately no way to pass the credential in the query string — it would end up in
access logs, in Referer headers and in analytics, and it would fragment every cache along the path.
If your page already has Product markup
Same call, different parameters — and you pass the @id of your own node instead of a name:
GET https://saz-es.public.shopadvizor.com/saz-ssr-server/v1/fragment?ean=4001724038818&lang=es&schema=rating-only&productId=https%3A%2F%2Fexample.com%2Fp%2F123%23product
Authorization: Basic <your credential>
What we emit then is deliberately minimal — no @type, because re-declaring it would fork the entity
instead of merging into yours. The widget page shows
the exact block.
Our block carries the same @id as yours, so both collapse into one entity and the rating
attaches to your product. If your Product node has no @id, add one first — the canonical product
URL with a fragment, as above. It is a single line and it is what makes this work.
productId is not optionalSearch engines show a review snippet only when the rating is attached to a supported entity, and
separate JSON-LD blocks on a page do not merge on their own. A bare AggregateRating is an orphan
node: valid JSON, ignored. Without productId we still emit that fallback — useful if you
post-process it — but it will not earn a rich result.
A third option, if neither fits
schema=none prints the widget with no JSON-LD, and you write the structured data yourself.
GET /v1/data gives you the numbers as JSON to nest inside your own Product node:
GET https://saz-es.public.shopadvizor.com/saz-ssr-server/v1/data?ean=4001724038818
Authorization: Basic <your credential>
{ "ean": "4001724038818", "average": 4.546153846153846, "total": 130, "counters": [...],
"name": "…", "brand": "…", "site": "es" }
Same credential, same rule: call it from your server, not from the browser. It answers with
Access-Control-Allow-Origin: * so a browser can call it, but doing so would mean shipping your
credential to every visitor.
name is Shopadvizor's catalogue product name. It is absent when the catalogue has none for that
EAN, so treat it as optional — and prefer your own title when you have one: the markup should match
what your page displays. brand is the catalogue brand, with the same availability. site is always es here: the product's page is
https://shopadvizor.com/es/<EAN>.
Most control, most work — and immune to every caveat above. Two things to respect if you take this path: round the average the same way you display it (a rating that disagrees with the visible one is a policy problem, not a rounding detail), and use a dot decimal in the markup regardless of your display locale.