Substantly

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.

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:

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.

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

LimitValue
Items per POST /catalog/items500
external_id length255 characters
canonical_url length2048 characters
Idempotency-Key retention24 hours
Requests per minute, sbl_live_120
Requests per minute, sbl_test_30
Locales accepted24 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

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.