Getting started
From nothing to a scanned catalogue. Nine steps, in the order they happen. Step 3 is the one integrations skip and then file a bug about — it is not optional and there is no plan that lifts it.
EU directive 2024/825 applies from 27 September 2026. Substantly reports risk on the environmental claims in your product copy, with a confidence and the article a reviewer should read. It is not a law firm, it issues no legal opinions, and it never edits your copy: suggested wording comes back for a person to approve.
1 · Create an account
Sign up at /register and confirm your address. The account belongs to an organization; everything below — shops, keys, catalogues, scans — is scoped to it and is never visible to another.
2 · Add your shop
In the console, Shops → Add a shop, and enter the
domain you sell from — sklep.pl, not a full URL. One shop
per domain. An agency integrating twenty shops adds twenty, each
verified by whoever runs it.
The new shop's status is unverified. In that state it
accepts no data at all: not by API, not by file upload,
not by any route added later.
3 · Prove you own the domain
This is the gate. A store checks only itself. There
is no mode in this product that scans somebody else's catalogue, and
an unverified shop answers 403 store_not_verified to
every write. If you are reading this page after an unexpected 403,
you are here.
The shop's page shows a token and two ways to publish it. The two are equal — pick whichever you can actually do. Shared hosting and hosted shop platforms routinely block DNS access, so the file method is a first-class route and not a fallback.
Method A — a DNS TXT record
Add a TXT record on the domain itself (some registrars write this as
@) with the value the console shows you. Your other TXT
records — SPF, DKIM — are unaffected; they live side by side. Publishing
can take a few minutes.
Method B — a file on the shop
Serve the file the console names, at the path it names, over
https on the domain you entered, containing the token and
nothing else. If you can deploy a file to your shop, you can do this;
you do not need DNS access.
Then press Check now. On success the shop becomes
verified and starts accepting data.
It is re-checked
Ownership is confirmed again every 30 days, and before
every full scan. Leave the record or the file in place. If it stops
resolving the shop moves to verification_lapsed: everything
already stored is kept, scans pause, and you have 14
days to put it back. A lapsed shop is refused with the same
store_not_verified code, and
store.verification_status on that response tells you which
of the two situations you are in.
4 · Create an API key
API keys → New key, on the shop you just verified. The key is shown in full once; store it in your secret manager before you leave the page. There is no endpoint that mints a key — a credential that can mint credentials is a credential worth stealing.
A key names one shop. Everything you send is filed against that shop and nothing in a request may name a different one — there is no store id in any path, query or body anywhere in this API. Three shops means three keys.
sbl_live_…— production.sbl_test_…— sandbox. See below.
Rotate a key from the same page. Rotation issues the successor and leaves the predecessor usable for a grace period, so a deploy does not have to be simultaneous with a secret change. Revoke immediately if a key leaks; the other keys on the shop are unaffected.
5 · Ask what you can scan
curl https://api.substantly.eu/api/v1/regulations \
-H "Authorization: Bearer sbl_live_..."
Call this instead of hardcoding regulation codes. Every known module is listed, including ones that are not built yet, with whether your organization is entitled to it and which ruleset version is current. A customer who buys a module later should not have to redeploy an integration to use it.
availability is available when a scan can run
the module now, and planned when it cannot — the same test
POST /scans applies, so the two endpoints cannot tell you
different things. entitled is the narrower question: may
you scan against it today. Send the codes where both are true.
A regulation is never part of a URL. There is no
/api/v1/ecgt/items. You have one catalogue, and which rules
it is measured against is a property of the result — so a
regulation arrives in a request body or as a query filter. This is why
step 5 exists: because the code is a value rather than a path, you need
a way to learn which values are yours.
6 · Send your first batch
POST /api/v1/catalog/items upserts up to
500 products in one call. Every item
you send gets a result row back, in the order you sent it.
Required on every item: external_id (your own identifier,
up to 255 characters), locale,
and fields. Optional: canonical_url and
published — and on an update, omitting one leaves
the stored value alone, so a stock sync that never mentions
published is not destructive.
cURL
curl -X POST https://api.substantly.eu/api/v1/catalog/items \
-H "Authorization: Bearer sbl_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"items": [
{
"external_id": "SKU-123",
"canonical_url": "https://sklep.pl/krem-nawilzajacy",
"locale": "pl",
"published": false,
"fields": {
"name": "Krem nawilzajacy BIO",
"description_short": "W 100% biodegradowalny, przyjazny dla srodowiska.",
"attributes": { "marka": "NaturaPL" }
}
}
]
}'
The answer:
{
"items": [
{ "index": 0, "external_id": "SKU-123", "status": "created" }
],
"summary": { "received": 1, "created": 1, "updated": 0, "unchanged": 0, "rejected": 0 }
}
If the shop is not verified you get 403 with
store_not_verified instead, and nothing is stored. Go back
to step 3.
PHP
<?php
$batch = ['items' => [[
'external_id' => 'SKU-123',
'canonical_url' => 'https://sklep.pl/krem-nawilzajacy',
'locale' => 'pl',
'published' => false,
'fields' => [
'name' => 'Krem nawilżający BIO',
'description_short' => 'W 100% biodegradowalny, przyjazny dla środowiska.',
'attributes' => ['marka' => 'NaturaPL'],
],
]]];
// One key for this batch, generated once and reused by every retry of it.
// A new key per attempt would defeat the whole mechanism.
$idempotencyKey = bin2hex(random_bytes(16));
$body = json_encode($batch, JSON_THROW_ON_ERROR);
function send(string $body, string $idempotencyKey): array
{
$curl = curl_init('https://api.substantly.eu/api/v1/catalog/items');
$headers = [];
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.getenv('CLAIMGUARD_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: '.$idempotencyKey,
],
// Retry-After is the only thing that makes a 429 actionable, so it is
// read rather than guessed at.
CURLOPT_HEADERFUNCTION => function ($curl, string $line) use (&$headers): int {
if (2 === count($parts = explode(':', $line, 2))) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
return [$status, json_decode((string) $response, true, 512, JSON_THROW_ON_ERROR), $headers];
}
for ($attempt = 1; ; $attempt++) {
[$status, $answer, $headers] = send($body, $idempotencyKey);
// Retry only what can change: a 429 or a 5xx. Everything else — including
// 200 and the 207 that means some rows were rejected — is an answer. A 403
// or a 422 will say the same thing forever, and hammering it spends your
// rate limit on a request that cannot succeed.
if (429 !== $status && $status < 500) {
break;
}
if ($attempt >= 5) {
throw new RuntimeException('Giving up after 5 attempts.');
}
sleep((int) ($headers['retry-after'] ?? min(60, 2 ** $attempt)));
}
printf(
"%d received, %d created, %d updated, %d unchanged, %d rejected\n",
$answer['summary']['received'],
$answer['summary']['created'],
$answer['summary']['updated'],
$answer['summary']['unchanged'],
$answer['summary']['rejected'],
);
foreach ($answer['items'] as $row) {
if ('rejected' === $row['status']) {
printf(" row %d: %s — %s\n", $row['index'], $row['error']['code'], $row['error']['detail']);
}
}
Python
import os
import time
import uuid
import httpx
BATCH = {
"items": [
{
"external_id": "SKU-123",
"canonical_url": "https://sklep.pl/krem-nawilzajacy",
"locale": "pl",
"published": False,
"fields": {
"name": "Krem nawilżający BIO",
"description_short": "W 100% biodegradowalny, przyjazny dla środowiska.",
"attributes": {"marka": "NaturaPL"},
},
}
]
}
# Generated once for this batch. Every retry of it sends the same key, which is
# what makes the retry safe: the second call is answered from the record rather
# than processed again, and comes back with `Idempotent-Replayed: true`.
idempotency_key = str(uuid.uuid4())
headers = {
"Authorization": f"Bearer {os.environ['CLAIMGUARD_API_KEY']}",
"Idempotency-Key": idempotency_key,
}
def send() -> httpx.Response:
with httpx.Client(base_url="https://api.substantly.eu", timeout=30) as client:
return client.post("/api/v1/catalog/items", json=BATCH, headers=headers)
for attempt in range(1, 6):
response = send()
# 200 and 207 are both answers, not failures: a 207 means some rows were
# rejected and the rest were stored.
if response.status_code in (200, 207):
break
# Retry only what can change. A 403 or a 422 is a decision, not a hiccup.
if response.status_code != 429 and response.status_code < 500:
response.raise_for_status()
wait = int(response.headers.get("Retry-After", min(60, 2**attempt)))
time.sleep(wait)
else:
raise RuntimeError("Giving up after 5 attempts.")
answer = response.json()
print(answer["summary"])
for row in answer["items"]:
if row["status"] == "rejected":
print(row["index"], row["error"]["code"], row["error"]["detail"])
Reading the answer
200 means every item was stored. 207 means
some were not, and the rest were — one bad product costs you that
product, never the other 499. Look at
items[].error.code; the full list is in the
reference.
index is what ties a result row back to a line in your
export. Use it rather than external_id, which is
null in exactly the case where you most need to find the
row: when external_id itself was unreadable.
unchanged is not a lesser created. It is the
right answer for almost every row of a nightly full-catalogue push, and
nothing is captured, queued or billed for one. If that count stays at
zero across nightly runs, something in your export is emitting spurious
changes — worth knowing before the invoice says so.
Checking drafts — the reason to integrate
"published": false is why you are integrating
rather than waiting for the crawler.
An unpublished item is ingested, stored and fully scannable. It just is not live on your site. That means you can check a product's copy before it is published — while a writer can still change a word, and before a claim you cannot substantiate is in front of a customer.
Nothing that reads your public shop can do this. A crawler sees what is already live, which is to say what is already exposure. Push your drafts.
For copy that is not in the catalogue at all — a draft in an editor,
a line somebody is still writing — call
POST /checks instead. It stores
nothing and answers in seconds.
A brand new item defaults to published: false. On an
update, omitting the field leaves it as it was — so flip it to
true in the same call that publishes the product on your
side, and the two stay in step.
Changing published, canonical_url or
locale updates the row without capturing new content:
no scannable text changed, so no scan is queued and nothing is billed
for the edit. Only a change to fields is a change.
Checking a draft before it is published
POST /api/v1/checks takes copy that is not in your
catalogue and answers within
3 seconds. Nothing is
stored — no item, no snapshot, no detection — so it is safe to call on
wording that may never ship. Send the same fields container
you would send to /catalog/items: it is read the same way,
against the same lexicon, so a check and the scan of the same copy
agree.
Never block publishing on this endpoint.
Every answer is 200, including the ones where the
analysis did not run. Read the status field, not the
status line. If Substantly is unwell it says so and gets out of your
way — a compliance tool that can stop a shop shipping a product is a
compliance tool that gets removed from the publish flow, and then it
protects nobody.
If you find yourself writing if (!response.ok) abort()
around this call, that branch will fire during our next incident and
your content team will not be able to publish anything.
cURL
curl -X POST https://api.substantly.eu/api/v1/checks \
-H "Authorization: Bearer sbl_live_..." \
-H "Content-Type: application/json" \
-d '{
"locale": "pl",
"regulations": ["ECGT"],
"fields": {
"name": "Krem nawilzajacy BIO",
"description_long": "W 100% biodegradowalny, przyjazny dla srodowiska."
}
}'
The answer:
{
"status": "complete",
"locale": "pl",
"regulations": ["ECGT"],
"ruleset_versions": { "ECGT": "2026.09.1" },
"findings": [
{
"regulation": "ECGT",
"field": "description_long",
"span": [9, 24],
"matched_text": "biodegradowalny",
"rule_id": "ANNEX_I_4A_GENERIC",
"legal_reference": "Dyrektywa (UE) 2024/825, art. 2 ust. 1 lit. b",
"claim_type": "generic_environmental",
"risk_level": "high",
"confidence": 0.82,
"rationale": "Biodegradability is claimed with no standard or timeframe named.",
"suggested_fix": "Name the standard the claim is substantiated against, or remove it.",
"layer": "llm"
}
],
"warning": null
}
field is the key you sent, and span is a pair
of offsets into that field's readable text — counted in characters, not
bytes, and after any markup has been stripped. Highlighting a finding is
a substring.
The three answers, and what to do with each
status |
What ran | What to do |
|---|---|---|
complete |
The lexicon and the classifier. |
Act on findings. An empty list is good news —
this is the only status where it means anything.
|
partial |
The lexicon only. |
Act on findings; they are real and citable.
Wording that implies a benefit without naming one
was not looked for, so treat the list as a floor rather than
a total.
|
unavailable |
Nothing. |
Publish anyway. findings is
empty and means nothing. Retry the check later, or rely on
your next catalogue scan.
|
warning is null on complete and
an object on the other two, carrying a stable code
(analysis_partial or analysis_unavailable) and
a sentence for a person. It is deliberately not an error envelope: a
degraded check is a success with less in it.
ruleset_versions names the lexicon each module actually ran
at. Store it beside anything you keep — it is what tells a claim
somebody fixed apart from a rule that changed.
PHP
<?php
function check(array $fields): array
{
$curl = curl_init('https://api.substantly.eu/api/v1/checks');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'locale' => 'pl',
'regulations' => ['ECGT'],
'fields' => $fields,
], JSON_THROW_ON_ERROR),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.getenv('CLAIMGUARD_API_KEY'),
'Content-Type: application/json',
],
// Slightly over our own deadline, so a slow network is a network
// problem rather than a mystery. Never wait longer: this call sits
// between a writer and the publish button.
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
// We could not reach Substantly at all. That is our problem, not the
// merchant's: publish, and let the nightly scan catch what we missed.
if (200 !== $status) {
return ['status' => 'unavailable', 'findings' => []];
}
return json_decode((string) $response, true, 512, JSON_THROW_ON_ERROR);
}
$answer = check([
'name' => 'Krem nawilżający BIO',
'description_long' => 'W 100% biodegradowalny, przyjazny dla środowiska.',
]);
switch ($answer['status']) {
case 'complete':
// The only case where an empty list means "clean".
$blocking = array_filter(
$answer['findings'],
static fn (array $f): bool => 'high' === $f['risk_level'],
);
if ([] !== $blocking) {
// Show them to the writer. Do not rewrite the copy: a person
// approves a change to wording, always.
showToTheAuthor($blocking);
}
break;
case 'partial':
// Real findings, an incomplete search. Show what came back and say so.
showToTheAuthor($answer['findings']);
noteThatTheCheckWasIncomplete($answer['warning']['detail']);
break;
case 'unavailable':
default:
// Including any status a future release adds: unknown means degraded,
// and degraded is never a reason to stop.
logIt($answer['warning']['detail'] ?? 'check unavailable');
break;
}
// In every branch above, publishing continues. That is the point.
publish();
Python
import os
import httpx
BLOCKING = {"high"}
def check(fields: dict) -> dict:
"""Ask about draft copy. Never raises, never blocks."""
try:
response = httpx.post(
"https://api.substantly.eu/api/v1/checks",
headers={"Authorization": f"Bearer {os.environ['CLAIMGUARD_API_KEY']}"},
json={"locale": "pl", "regulations": ["ECGT"], "fields": fields},
# Slightly over Substantly's own 3-second deadline. Never longer:
# this call sits between a writer and the publish button.
timeout=5.0,
)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
# We could not reach Substantly. Publish; the next catalogue scan
# will find whatever this call would have.
return {"status": "unavailable", "findings": [], "warning": None}
answer = check({
"name": "Krem nawilżający BIO",
"description_long": "W 100% biodegradowalny, przyjazny dla środowiska.",
})
if answer["status"] == "complete":
# The only status where an empty list means the copy is clean.
risky = [f for f in answer["findings"] if f["risk_level"] in BLOCKING]
if risky:
show_to_the_author(risky)
elif answer["status"] == "partial":
# Real findings, an incomplete search: show them, and say it was partial.
show_to_the_author(answer["findings"])
note_that_the_check_was_incomplete(answer["warning"]["detail"])
else:
# `unavailable`, and anything a future release adds. Unknown means
# degraded, and degraded is never a reason to stop.
log(answer.get("warning", {}).get("detail", "check unavailable"))
# Every branch reaches this line. That is the whole design.
publish()
Refusals
Three things are refused rather than answered, because none of them is our analysis being unwell:
-
403 store_not_verified— the shop has not proved it owns its domain. Go back to step 3. -
403 entitlement_required— your plan does not include the pre-publication check, or does not include the module you asked for. Theupgrademember names what lifts it. -
422 unknown_regulation— the code names no module. That is a different problem from the one above and has a different fix: call GET /regulations and send a code that exists, rather than buying anything.
A body we cannot read — no locale, no
regulations, no fields, or more than
20000 characters of copy
across all fields — is 422 invalid_request. Retrying it
unchanged will be refused identically.
Checks are metered on their own budget, apart from the catalogue: 300 per minute on a live key and 60 on a test one. A busy editor cannot spend your nightly push's allowance, and a nightly push cannot stand between a writer and this call.
Retrying safely
Send an Idempotency-Key header on every write. Any string
unique to the batch; a UUID is the obvious choice.
-
Same key, same body — you get the first response
back, byte for byte, with
Idempotent-Replayed: true, and the work is not done twice. -
Same key, different body — refused with
409 idempotency_key_conflict. Reusing the key would silently drop one of the two batches. -
Same key, first call still running —
409 idempotency_key_in_progress. Wait and retry; the batch is not lost.
Keys are remembered for 24 hours, per API key — two customers who happen to pick the same UUID never see each other's answers. A batch that was refused gives its key back, so a corrected retry after fixing verification or a bad row is not met with a conflict.
Generate the key once per batch and reuse it across attempts. A fresh key on every attempt is the one mistake that makes the header useless.
7 · Trigger a scan
planned Specified in the reference; arrives with the scan API.
curl -X POST https://api.substantly.eu/api/v1/scans \
-H "Authorization: Bearer sbl_live_..." \
-H "Content-Type: application/json" \
-d '{ "regulations": ["ECGT"] }'
Regulation in the body, from the codes step 5 told you are yours. Omit
scope to scan the whole catalogue, or pass
{ "scope": { "external_ids": ["SKU-123"] } } for named
products.
A catalogue scan is asynchronous, so this answers 202 with
the scan id and a Location header. Ownership is re-checked
before a full scan runs — another reason to leave the DNS record or the
file in place.
8 · Poll the scan
planned Specified in the reference.
curl https://api.substantly.eu/api/v1/scans/018f3c2a-0000-7000-8000-000000000000 \
-H "Authorization: Bearer sbl_live_..."
Progress while it runs; counts by risk level, the
ruleset_version each regulation ran at, and a
report_id when it is done. The ruleset version is recorded
with the scan rather than looked up afterwards, so a re-run of the same
catalogue at the same version gives the same answer.
Poll on an interval, not in a tight loop — every poll spends rate limit.
9 · Fetch the report
planned Specified in the reference.
curl https://api.substantly.eu/api/v1/reports/$REPORT_ID \
-H "Authorization: Bearer sbl_live_..." \
-H "Accept: application/pdf" -o report.pdf
The findings with their legal references, and the ruleset version they were produced at. The shop's name and domain on a report come from the ownership you proved in step 3 and never from a field anybody typed — a document asserting whose shop this is is only worth having if that was checked.
Limits and headers
| Limit | Value |
|---|---|
Items per POST /catalog/items | 500 |
external_id length | 255 characters |
canonical_url length | 2048 characters |
Idempotency-Key retention | 24 hours |
Requests per minute, sbl_live_ | 120 |
Requests per minute, sbl_test_ | 30 |
| Locales accepted | 24 official EU languages, optionally with a region: pl or pl-PL |
Over the ceiling you get 429 rate_limit_exceeded with
Retry-After. You should never see it: every response —
successes included — carries
RateLimit-Limit— the ceiling for this key.RateLimit-Remaining— what is left this minute.RateLimit-Reset— seconds until the window rolls over.
Watch RateLimit-Remaining and slow down before you are
refused. The limit is per key: a second key for a nightly batch job gets
its own budget, so the job cannot starve your shop's live traffic.
Send the language your copy is actually in. All 24 official EU languages are accepted, not only the ones a lexicon exists for today — mislabelling German copy as Polish to get it accepted has it read against the wrong lexicon, which produces findings nobody can defend.
Sandbox
Issue a sbl_test_ key for development and CI. It
authenticates against the same API and the same data as a
sbl_live_ key — there is no separate sandbox
catalogue, because a sandbox that answers differently from production is
a sandbox that certifies nothing.
What differs is the budget: 30 requests a minute against 120, metered separately. A development loop cannot spend the production allowance of the shop it belongs to, and the low ceiling is the honest signal that this half of the API is not where production traffic belongs.
The shop still has to be verified. There is no test mode that skips
step 3 — that would be a mode in which Substantly scans a catalogue
nobody proved they own, which is the one thing this product does not do.
Verify a domain you control, point a sbl_test_ key
at it, and push items with "published": false: they are
stored and scannable and never confused with your live catalogue.
Ready for the details? The full API reference covers every endpoint, every error code and every schema, and the OpenAPI document is there to point a client generator at.