Substantly

Substantly API v1.0

Push a product catalogue and check it for environmental-claim risk under EU directive 2024/825.

Substantly scans e-commerce product copy for environmental claims that EU directive 2024/825 (EmpCo/ECGT) bans without documented substantiation from 27 September 2026. This API is how a shop or an agency gets a catalogue in and results out without touching the web console.

Before anything else: verify the shop

A store checks only itself. Every store must prove it owns its domain before it accepts any data — over this API, by file upload, or by any route added later. An unverified store answers 403 with the code store_not_verified to every write, and there is no mode, flag or plan that turns that off.

Ownership is proved once in the web console, at /app/stores, by either of two equal methods: a DNS TXT record, or a file served from the shop's own domain. The console shows both. Ownership is re-checked every 30 days; a store whose proof stops resolving moves to verification_lapsed, keeps everything already stored, pauses scans, and has 14 days to fix it. A lapsed store is refused with the same 403 and the same code, and the store.verification_status field on that response tells the two apart.

This is the step integrations skip. If you are reading this after a 403 you did not expect, this is why.

Authentication

Every request carries an API key as a bearer token:

Authorization: Bearer sbl_live_...

Keys are issued, rotated and revoked in the web console at /app/api-keys; there is no endpoint that mints a key, because a credential that can mint credentials is a credential worth stealing. A key is shown in full exactly once, at creation.

A key names a shop. The store a request acts on is read from the credential and from nowhere else — there is no store id in any path, query or body. One key, one shop; a customer with three shops issues three keys.

Two prefixes:

Prefix Purpose Rate limit
sbl_live_ Production traffic 120 requests/minute
sbl_test_ Sandbox — development and CI 30 requests/minute

A sbl_test_ key is metered separately, so a development loop cannot spend the production budget of the shop it belongs to. It is otherwise the same API against the same data: there is no separate sandbox catalogue, because a sandbox that answers differently from production is a sandbox that certifies nothing.

Two conventions worth reading before you build

Regulation is never a path segment. There is no /api/v1/ecgt/items. You have one catalogue, and a regulation is a dimension of the result, not of the resource — so regulation arrives as a body field (POST /scans { "regulations": ["ECGT"] }) or as a query filter (GET /detections?regulation=ECGT). Ask GET /regulations which codes your organization may use rather than hardcoding a list.

Resources are plural nouns, including where the operation is RPC-shaped. POST /checks returns a result synchronously today. When a check outgrows its time budget it will return 202 with a Location header pointing at GET /checks/{id}, and that is not a new contract — it is the same collection answering with a receipt instead of a result. Write clients that tolerate a 202 with a Location on any POST here.

Versioning

The version is in the path: /api/v1/. Inside v1 changes are additive — new endpoints, new optional request fields, new response fields, new enum members on fields documented as open. Treat unknown response fields as ignorable and unknown enum members as "something newer than my client". Removing a field, tightening validation or changing the meaning of an existing one requires /api/v2/, which would run beside v1, not replace it.

Limits

Limit Value
Items per POST /catalog/items 500
Claims per page of GET /claims 200 (limit, default 50)
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

Every authenticated response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset — on successes too, not only on the refusal, so a well-written integration never has to be refused to learn where the ceiling is. A 429 additionally carries Retry-After.

Errors

Every error is application/problem+json (RFC 9457) with the same five fields. Branch on code. type is a URL that may be re-hosted, title and detail are prose that will be rewritten and translated, and status is too coarse to act on — three different 409s want three different fixes.

Some errors carry extra members beside the five: batch_too_large carries limit, and store_not_verified carries store.

A POST /catalog/items in which some items are unusable is not an error. It answers 207 with a per-item result row for every item you sent, and one bad product costs you that product rather than the other 499.

What we do and do not say

Substantly reports risk, with a confidence, and cites the article a reviewer should read. It does not issue legal opinions and it never says a product is non-compliant. It also never edits a merchant's copy: suggested wording is returned for a human to approve.

All processing and storage is in the EU.

Base URL

Endpoints

An endpoint marked planned has its shape fixed in the contract but is not served yet. The shape is published early on purpose: the endpoint map may not change once integrators have built against it, so it is decided before the code exists rather than after.

POST /api/v1/catalog/items

Upsert a batch of catalogue items

Sends up to 500 products in one call. Each item is created, updated or recognised as unchanged, and every item you send gets a result row back in the order you sent it.

The store is the one your key belongs to. There is no store parameter anywhere in this request.

Absent means unchanged. external_id, locale and fields are required on every item. canonical_url and published are optional, and omitting one on an update leaves the stored value alone — so a price-and-stock sync that never mentions published is not destructive. A brand new item defaults to published: false.

Change detection is over fields alone. 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.

published: false is the reason to integrate. An unpublished item is stored and fully scannable, which means you can check a product before it is live on your site. Nothing that reads your public shop can do that, and it is the single thing this API gives you that a crawler never will. Push drafts.

A duplicate external_id inside one batch: the last occurrence is stored and every earlier one is reported as duplicate_external_id. Letting both through would leave which content survived up to flush order.

Unknown keys on an item are ignored rather than refused — shop platforms export what they export. The one exception is that nothing naming a regulation is read from this payload at all.

Parameters

NameInRequiredDescription
Idempotency-Key header no

Makes a retry safe. Send any string unique to the batch — a UUID is the obvious choice.

Send the same key with the same body and you get the first response back, byte for byte, with Idempotent-Replayed: true, and the work is not done twice. Send the same key with a different body and the request is refused with idempotency_key_conflict: reusing it would silently drop one of the two batches.

Keys are remembered for 24 hours, per API key — two customers who pick the same UUID never see each other's answers.

A batch that was refused gives its key back, so the corrected retry is not met with a conflict.

Omitting the header is fine and means no replay protection. It is only useful to a client that intends to retry — which, on a network, is every client that matters.

Request body

application/json, required

FieldTypeNotes
sync_id string (uuid)

The session this batch belongs to, from POST /api/v1/syncs. Omit it and the push behaves exactly as it always has, marking nothing. A sync_id that is not this store's open session refuses the whole batch with unknown_sync and stores nothing.

items array required

At most 500. An empty array is refused.

FieldTypeNotes
external_id string required

Your own identifier, trimmed. A leading space is invisible in a spreadsheet and would file the same product twice.

locale string required

An official EU language, optionally with a two-letter region subtag — pl or pl-PL. Both are accepted and stay distinct, because your platform emits one of them and a report that renamed it would not match your export.

The allowlist is all 24 official languages of the Union, not the one language a lexicon exists for today. Send your German copy as de; mislabelling it as pl to get it accepted would have it read against the wrong lexicon, which is worse than not being read at all.

fields object required

The product's content, keyed by field name. Whatever your platform calls its columns: name, description_short, description_long, and a nested attributes object are the usual shape. This is the only part of the item that is scanned, and the only part that decides whether a snapshot is new.

canonical_url string or null (uri)

The product's public URL. Recommended, not required — nothing about scanning needs it, and a report links its findings to it.

It must be on the domain this store proved it owns, or a subdomain of it. www.sklep.pl under a verified sklep.pl is fine; sklep.pl.somewhere-else.tld is a different registrable domain and is refused. A catalogue of rows pointing at somebody else's shop is the beginning of a tool for auditing other people's shops, which this deliberately is not.

Omitted means unchanged on an update.

published boolean or null

Whether the product is live on your site. Defaults to false on a new item; omitted means unchanged on an update.

An unpublished item is stored and fully scannable. Checking copy before it goes live is the one thing an integration can do that reading a public shop cannot.

A single product, with a draft that is not live yet

{
    "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.",
                "description_long": "Nasza formuła jest neutralna klimatycznie…",
                "attributes": {
                    "marka": "NaturaPL",
                    "kategoria": "Pielęgnacja twarzy"
                }
            }
        }
    ]
}

A stock sync that touches nothing but `fields`

{
    "items": [
        {
            "external_id": "SKU-123",
            "locale": "pl",
            "fields": {
                "name": "Krem nawilżający BIO",
                "description_short": "W 100% biodegradowalny, przyjazny dla środowiska."
            }
        }
    ]
}

Responses

StatusMeaning
200

Every item was stored. Nothing was rejected.

207

At least one item was rejected and the rest were stored. Read items[].error.code for the rows that failed; summary.rejected is the count.

400

The request body is not valid JSON.

401

The key is missing, malformed, revoked or expired.

403

The store is not verified (store_not_verified), or the plan's catalogue ceiling would be crossed by this batch (entitlement_required, with limit.key = catalog_items). code separates the two.

Either way the batch was not processed: nothing was written, not even the items that would have fitted, and an Idempotency-Key the request carried is released, so the retry after fixing the cause is not met with a conflict. A catalogue already over its plan's ceiling keeps every item and keeps being scanned; what is refused is growth, and upgrade names the plan that would hold it.

409

One Idempotency-Key was used for two different things, and the two codes want opposite responses from you.

idempotency_key_conflict — the key already settled a different body. Use a new key.

idempotency_key_in_progress — the first request with this key is still running. Wait and retry the same request; the batch is not lost.

422

The batch could not be read at all, so nothing was stored. This is not the same as an unusable item, which is reported per row with a 207.

invalid_request means the envelope is wrong — no items array, or an empty one. An empty batch is refused rather than answered with an empty success: it is always a caller bug, and a 200 is how that bug reaches production unnoticed.

batch_too_large names the ceiling in a limit member, so a client that has to split its batch does not have to parse the number out of a sentence that will be translated.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

200 shape and examples
HeaderDescription
RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

Idempotent-Replayed

Present and true when this response came out of the idempotency record rather than from work done again. Absent otherwise.

application/json

FieldTypeNotes
items array required

One row per item you sent, in the order you sent them.

FieldTypeNotes
index integer required

The item's position in the batch you sent. This is what ties a row back to a line in your export — external_id is null exactly when the failure was that it could not be read, which is when you most need to know which line to look at.

external_id string or null required
status string required

unchanged is not a lesser updated: it is the right answer for almost every row of a nightly full-catalogue push, and nothing is captured, queued or billed for one.

One of: created, updated, unchanged, rejected

error object

Present exactly when status is rejected.

FieldTypeNotes
code string required

Why one item in a batch was not stored. These appear in items[].error.code on a 207, never as the code of a problem document — an unusable item is not a failed request.

One of: invalid_item, invalid_external_id, unsupported_locale, invalid_fields, invalid_canonical_url, duplicate_external_id

detail string required

What was wrong. It quotes at most 64 characters of what you sent — an error body is the least guarded thing this API emits, and a rejection echoing a whole product description would put a merchant's copy in somebody else's log aggregator.

summary object required

Redundant with the rows, and worth having: an integrator watching a nightly push wants one line in a log, and computing it in every client is how four clients compute it four slightly different ways.

FieldTypeNotes
received integer required
created integer required
updated integer required
unchanged integer required
rejected integer required

allStored

{
    "items": [
        {
            "index": 0,
            "external_id": "SKU-123",
            "status": "created"
        },
        {
            "index": 1,
            "external_id": "SKU-124",
            "status": "unchanged"
        }
    ],
    "summary": {
        "received": 2,
        "created": 1,
        "updated": 0,
        "unchanged": 1,
        "rejected": 0
    }
}
207 shape and examples
HeaderDescription
RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

Idempotent-Replayed

Present and true when this response came out of the idempotency record rather than from work done again. Absent otherwise.

application/json

FieldTypeNotes
items array required

One row per item you sent, in the order you sent them.

FieldTypeNotes
index integer required

The item's position in the batch you sent. This is what ties a row back to a line in your export — external_id is null exactly when the failure was that it could not be read, which is when you most need to know which line to look at.

external_id string or null required
status string required

unchanged is not a lesser updated: it is the right answer for almost every row of a nightly full-catalogue push, and nothing is captured, queued or billed for one.

One of: created, updated, unchanged, rejected

error object

Present exactly when status is rejected.

FieldTypeNotes
code string required

Why one item in a batch was not stored. These appear in items[].error.code on a 207, never as the code of a problem document — an unusable item is not a failed request.

One of: invalid_item, invalid_external_id, unsupported_locale, invalid_fields, invalid_canonical_url, duplicate_external_id

detail string required

What was wrong. It quotes at most 64 characters of what you sent — an error body is the least guarded thing this API emits, and a rejection echoing a whole product description would put a merchant's copy in somebody else's log aggregator.

summary object required

Redundant with the rows, and worth having: an integrator watching a nightly push wants one line in a log, and computing it in every client is how four clients compute it four slightly different ways.

FieldTypeNotes
received integer required
created integer required
updated integer required
unchanged integer required
rejected integer required

oneBadRow

{
    "items": [
        {
            "index": 0,
            "external_id": "SKU-123",
            "status": "created"
        },
        {
            "index": 1,
            "external_id": "SKU-124",
            "status": "rejected",
            "error": {
                "code": "invalid_canonical_url",
                "detail": "canonical_url must be an http(s) URL on \"sklep.pl\" or a subdomain of it: it is on another domain."
            }
        }
    ],
    "summary": {
        "received": 2,
        "created": 1,
        "updated": 0,
        "unchanged": 0,
        "rejected": 1
    }
}
400 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-json",
    "title": "Malformed JSON",
    "status": 400,
    "code": "invalid_json",
    "detail": "The request body is not valid JSON."
}
401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
403 shape and examples

application/problem+json

409 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

reused

{
    "type": "https://substantly.eu/problems/idempotency-key-conflict",
    "title": "Idempotency key conflict",
    "status": 409,
    "code": "idempotency_key_conflict",
    "detail": "This Idempotency-Key was already used for a different request body. Use a new key for a new batch."
}

inProgress

{
    "type": "https://substantly.eu/problems/idempotency-key-in-progress",
    "title": "Idempotency key conflict",
    "status": 409,
    "code": "idempotency_key_in_progress",
    "detail": "A request with this Idempotency-Key is still being processed. Retry in a moment; the batch is not lost."
}
422 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

FieldTypeNotes
limit integer

The ceiling that was exceeded. Present on batch_too_large.

noItems

{
    "type": "https://substantly.eu/problems/invalid-request",
    "title": "Invalid request",
    "status": 422,
    "code": "invalid_request",
    "detail": "The request body must carry an \"items\" array of catalogue items."
}

tooLarge

{
    "type": "https://substantly.eu/problems/batch-too-large",
    "title": "Invalid request",
    "status": 422,
    "code": "batch_too_large",
    "detail": "A batch carries at most 500 items; 501 were sent. Split the batch — nothing in this request was stored.",
    "limit": 500
}
429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

GET /api/v1/catalog/items/{externalId}

Read one catalogue item back

The product as this shop last sent it, plus metadata about the content snapshot we hold — enough to tell whether the revision you sent is the revision we have, without downloading it again.

An identifier that does not exist and one belonging to somebody else are the same 404. A 403 would confirm the identifier exists, which turns this into a way of reading a competitor's SKU list one guess at a time.

Parameters

NameInRequiredDescription
externalId path yes

The merchant's own identifier for the product, exactly as it was sent.

Responses

StatusMeaning
200

The item.

401

The key is missing, malformed, revoked or expired.

403

The shop has not proved it owns its domain, or its proof lapsed. The request was authenticated and understood — what is missing is the customer's evidence that the shop is theirs, which is exactly what "forbidden" means.

store.verification_status separates "never finished setting this up" (unverified) from "this worked last month and stopped" (verification_lapsed). A lapsed store keeps everything already stored and has 14 days to fix its record; nothing has been deleted.

Fix it in the console at /app/stores, then retry the same batch.

404

No such item in this store. An identifier that never existed and one belonging to another organization are deliberately the same answer.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

200 shape and examples
HeaderDescription
RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/json

FieldTypeNotes
external_id string required
canonical_url string or null required
locale string required

An official EU language, optionally with a two-letter region subtag — pl or pl-PL. Both are accepted and stay distinct, because your platform emits one of them and a report that renamed it would not match your export.

The allowlist is all 24 official languages of the Union, not the one language a lexicon exists for today. Send your German copy as de; mislabelling it as pl to get it accepted would have it read against the wrong lexicon, which is worse than not being read at all.

published boolean required
removed_at string or null (date-time) required

When this product left the catalogue, or null while it is in it. A removed product stays readable here rather than answering 404, and keeps its claims, their evidence and their history; it is out of the scan scope and does not count toward your plan's catalogue ceiling. Push it again and it comes back.

fields object required
snapshot object required

Metadata about the content we hold, not the content itself — the content is in fields. What a client needs from here is whether the revision it sent is the revision we have.

created_at string (date-time) required
updated_at string (date-time) required
401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
403 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

FieldTypeNotes
store object required
FieldTypeNotes
id string (uuid) required
verification_status string required

unverified — ownership was never proved. verification_lapsed — it was proved and stopped resolving; data is kept and scans are paused for 14 days.

One of: unverified, verification_lapsed

neverVerified

{
    "type": "https://substantly.eu/problems/store-not-verified",
    "title": "Store not verified",
    "status": 403,
    "code": "store_not_verified",
    "detail": "This shop has not proved it owns \"sklep.pl\", so it accepts no product data. Publish the verification record or file, then check again.",
    "store": {
        "id": "018f3c2a-0000-7000-8000-000000000000",
        "verification_status": "unverified"
    }
}

lapsed

{
    "type": "https://substantly.eu/problems/store-not-verified",
    "title": "Store not verified",
    "status": 403,
    "code": "store_not_verified",
    "detail": "Ownership of \"sklep.pl\" could not be confirmed again, so it accepts no new product data until it is verified. Everything already stored is kept.",
    "store": {
        "id": "018f3c2a-0000-7000-8000-000000000000",
        "verification_status": "verification_lapsed"
    }
}
404 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/catalog-item-not-found",
    "title": "Catalogue item not found",
    "status": 404,
    "code": "catalog_item_not_found",
    "detail": "No catalogue item with that identifier exists in this store."
}
429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

DELETE /api/v1/catalog/items/{externalId} planned

Remove a product from the catalogue

Planned. The path is reserved so that it cannot be claimed by anything else; it is not served yet. Removing a product removes it from future scans and keeps the findings already recorded against it, because a merchant who deletes a product still has to be able to show what was on the page when it was live.

Parameters

NameInRequiredDescription
externalId path yes

The merchant's own identifier for the product, exactly as it was sent.

Responses

StatusMeaning
204

The item is gone from the catalogue.

401

The key is missing, malformed, revoked or expired.

404

No such item in this store. An identifier that never existed and one belonging to another organization are deliberately the same answer.

501

A reserved path that is not served yet. It is a 501 and not a 404 on purpose: "not built" and "wrong URL" are different problems, and an integrator should be able to tell which one they have.

A path that has not yet been reserved in the router answers 404 instead. Do not read either as a permanent answer about the endpoint — x-substantly-status on the operation is what says whether it is built, and it is the field to check.

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
404 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/catalog-item-not-found",
    "title": "Catalogue item not found",
    "status": 404,
    "code": "catalog_item_not_found",
    "detail": "No catalogue item with that identifier exists in this store."
}
501 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

POST /api/v1/catalog/prices planned

Push a price time series

Planned. Prior-price history for the Omnibus directive's "lowest price in the last 30 days" rule. Deliberately a separate resource rather than a field on an item: a price series changes daily while product copy changes rarely, and folding it into POST /catalog/items would make every price update look like a content change and queue a scan for it.

Responses

StatusMeaning
202

The series was accepted.

401

The key is missing, malformed, revoked or expired.

501

A reserved path that is not served yet. It is a 501 and not a 404 on purpose: "not built" and "wrong URL" are different problems, and an integrator should be able to tell which one they have.

A path that has not yet been reserved in the router answers 404 instead. Do not read either as a permanent answer about the endpoint — x-substantly-status on the operation is what says whether it is built, and it is the field to check.

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
501 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

POST /api/v1/syncs

Declare that a full catalogue push is about to start

Opens a sync session. Nothing has happened to your catalogue yet — this is a declaration of intent, which is why it answers 202 and a Location rather than 201.

Why this exists. POST /catalog/items is a batch upsert capped at 500 products whose contract is "absent means unchanged", and a full first-connect push and a single debounced product save arrive here in exactly the same shape. There is no way for us to tell them apart, so we never infer that a product you did not send has been removed — inferring it would orphan your whole catalogue the first time your plugin saved one product. Completeness is something you declare.

How to use it. Open a session, put its id in the sync_id of every batch of the push, then PATCH it with {"status": "complete"}. Products of this store that no batch of the session carried are marked removed on that call and on no other.

One open session per store. A second POST while one is open is a 409 naming the open one — two sessions would each mark the other's batches absent. A session nobody completes expires 24 hours after it was opened and marks nothing, so a crashed integration costs you a session and never a catalogue.

Removal is a soft delete. The product keeps its identifier, its claims, their evidence and their history; it leaves the scan scope and stops counting toward your plan's catalogue ceiling, and it comes back the moment you push it again.

Responses

StatusMeaning
202

The session is open. Poll the Location, and put id in every batch.

401

The key is missing, malformed, revoked or expired.

403

The store has not proved it owns its domain. There is no plan or flag that lifts this.

409

This store already has an open session (sync_already_open). The detail names it and says when it expires. Complete that one, or wait for it to expire.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

202 shape and examples
HeaderDescription
Location

The polling URL for this session.

application/json

FieldTypeNotes
id string (uuid) required
status string required

open accepts batches; completed has decided what left the catalogue; expired decided nothing and never will. Treat this enum as open, as with every other enum in this contract.

One of: open, completed, expired

opened_at string (date-time) required
expires_at string (date-time) required

24 hours after it was opened. A session nobody closes marks nothing.

closed_at string or null (date-time) required

When it was completed, or when it was written off as expired.

summary object required

What the session has done. Present from the first poll, at zero: the counts mean the same thing before and after it closes.

FieldTypeNotes
items_seen integer required

How many items the batches of this session carried.

items_removed integer required

How many it marked removed when it closed.

items_restored integer required

And how many it brought back.

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
403 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

FieldTypeNotes
store object required
FieldTypeNotes
id string (uuid) required
verification_status string required

unverified — ownership was never proved. verification_lapsed — it was proved and stopped resolving; data is kept and scans are paused for 14 days.

One of: unverified, verification_lapsed

409 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

GET /api/v1/syncs/{id}

Poll a sync session

The read the 202 points at. status can move from open to expired without anybody calling anything: the 24 hours are resolved when the session is looked at, so what you read here is what a push would get.

summary is present from the first poll, at zero, rather than appearing when the session closes — the fields mean the same thing on either side of the finish line, so one shape is read throughout.

A session of another organization is a 404, and so is one of another shop of the same organization, and so is an identifier that is not a UUID. Telling those apart would confirm that a competitor is mid-sync.

Parameters

NameInRequiredDescription
id path yes

Responses

StatusMeaning
200

The session.

401

The key is missing, malformed, revoked or expired.

404

No sync session with that identifier belongs to this store.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

200 shape and examples

application/json

FieldTypeNotes
id string (uuid) required
status string required

open accepts batches; completed has decided what left the catalogue; expired decided nothing and never will. Treat this enum as open, as with every other enum in this contract.

One of: open, completed, expired

opened_at string (date-time) required
expires_at string (date-time) required

24 hours after it was opened. A session nobody closes marks nothing.

closed_at string or null (date-time) required

When it was completed, or when it was written off as expired.

summary object required

What the session has done. Present from the first poll, at zero: the counts mean the same thing before and after it closes.

FieldTypeNotes
items_seen integer required

How many items the batches of this session carried.

items_removed integer required

How many it marked removed when it closed.

items_restored integer required

And how many it brought back.

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
404 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

PATCH /api/v1/syncs/{id}

Close a sync session and mark what it never saw

{"status": "complete"} and nothing else — there is one transition and the body names it. Anything else is a 422 rather than a quiet no-op, because a client that believes it closed a session and did not will push its next catalogue into a session that is still open.

This is the only call in the API that removes a product. Items of this store with no removed_at that no batch of this session carried get one; items the session did carry that were removed have it cleared. Nothing is deleted: the row, its claims, their evidence and their history all stay, and the claims read as orphaned until the product comes back.

Completing twice is 200 twice with the same summary, and the second call marks nothing. If your connection drops on the response, send it again.

A session that expired cannot be completed (sync_expired). Open a new one and push again — an expired session has decided nothing, and a week-old one closing over a catalogue that has moved on is exactly what the expiry prevents.

Parameters

NameInRequiredDescription
id path yes

Request body

application/json, required

FieldTypeNotes
status string required

The only transition this resource has.

One of: complete

close

{
    "status": "complete"
}

Responses

StatusMeaning
200

The session is closed. summary says what it did.

400

The request body is not valid JSON.

401

The key is missing, malformed, revoked or expired.

403

The store has not proved it owns its domain.

404

No sync session with that identifier belongs to this store.

409

The session expired and marked nothing (sync_expired). Open a new one.

422

The body is not {"status": "complete"}.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

200 shape and examples

application/json

FieldTypeNotes
id string (uuid) required
status string required

open accepts batches; completed has decided what left the catalogue; expired decided nothing and never will. Treat this enum as open, as with every other enum in this contract.

One of: open, completed, expired

opened_at string (date-time) required
expires_at string (date-time) required

24 hours after it was opened. A session nobody closes marks nothing.

closed_at string or null (date-time) required

When it was completed, or when it was written off as expired.

summary object required

What the session has done. Present from the first poll, at zero: the counts mean the same thing before and after it closes.

FieldTypeNotes
items_seen integer required

How many items the batches of this session carried.

items_removed integer required

How many it marked removed when it closed.

items_restored integer required

And how many it brought back.

400 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-json",
    "title": "Malformed JSON",
    "status": 400,
    "code": "invalid_json",
    "detail": "The request body is not valid JSON."
}
401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
403 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

FieldTypeNotes
store object required
FieldTypeNotes
id string (uuid) required
verification_status string required

unverified — ownership was never proved. verification_lapsed — it was proved and stopped resolving; data is kept and scans are paused for 14 days.

One of: unverified, verification_lapsed

404 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

409 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

422 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

POST /api/v1/scans

Scan the catalogue against one or more regulations

Regulation arrives in the body. Ask GET /regulations which codes this organization is entitled to rather than hardcoding them; a code you have no entitlement for is a 403 with entitlement_required, and a code this deployment cannot run is a 422 with unknown_regulation — two different problems with two different fixes. One is fixed by buying the module, the other by sending a code that exists.

A catalogue scan is asynchronous from day one: this returns 202 with the scan id and a Location header pointing at GET /scans/{id}. Follow the header rather than assembling the URL yourself.

scope is optional. Omit it to scan the whole catalogue, or pass external_ids to scan named products. An external_id that is not in your catalogue is skipped in silence — it is not distinguishable from one that belongs to somebody else, and it must not be. scope is an object rather than a bare array so that later scope types can be added beside external_ids without a breaking change; a key this version does not recognise is a 422 rather than something quietly ignored, because an ignored scope means the whole catalogue was scanned instead.

The shop is the one your API key belongs to. There is no store identifier in this body, and no mode in which a scan reads a catalogue whose domain the account has not proved it owns.

Request body

application/json, required

FieldTypeNotes
regulations array required
scope object

Omit to scan the whole catalogue. An object rather than a bare array so that later scope types can be added beside external_ids; a key this version does not recognise is refused rather than ignored.

FieldTypeNotes
external_ids array

The identifiers you sent to POST /catalog/items, matched exactly. One that is not in your catalogue is skipped in silence.

wholeCatalogue

{
    "regulations": [
        "ECGT"
    ]
}

namedProducts

{
    "regulations": [
        "ECGT"
    ],
    "scope": {
        "external_ids": [
            "SKU-123",
            "SKU-124"
        ]
    }
}

Responses

StatusMeaning
202

The scan was queued. Poll the Location.

400

The body is not valid JSON. Nothing was read, so nothing was queued.

401

The key is missing, malformed, revoked or expired.

403

The store is not verified, or the organization is not entitled to one of the regulations it asked for, or the plan's scan allowance is spent. Every one of these leaves no scan behind — a refusal never costs an allowance.

422

A regulation code this deployment cannot run, an empty or absent regulations, or a malformed scope.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

202 shape and examples
HeaderDescription
Location

The polling URL for this scan.

application/json

FieldTypeNotes
id string (uuid) required
status string required

One of: queued

400 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
403 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

422 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

GET /api/v1/scans/{id}

Poll a scan

Progress while it runs, counts by risk level when it is done, the ruleset_version each regulation was run at, and the report id once one exists. ruleset_version is recorded per scan and not looked up later, so a re-run of the same catalogue at the same version is reproducible.

counts_by_risk is present from the first poll, at zero, rather than appearing when the scan finishes: the fields mean the same thing on either side of the finish line, so a client reads one shape throughout. Poll on statusreport_id is null until a report exists, and a loop built around it would keep running after the document was ready.

Another organization's scan is a 404, not a 403. So is an identifier that is not a UUID: telling the two apart would tell a caller which of their guesses were well-formed.

Parameters

NameInRequiredDescription
id path yes

Responses

StatusMeaning
200

The scan.

401

The key is missing, malformed, revoked or expired.

404

No scan with that id belongs to this organization.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

200 shape and examples

application/json

FieldTypeNotes
id string (uuid) required
status string required

Where a scan stands. paused means the organization's daily analysis budget ran out: nothing is lost and the scan resumes when the budget resets, which is why it is not a terminal state. A scan whose worker died goes back to queued rather than to failed — a bulk scan never reports a catalogue as clean because something stopped reading it.

Treat this enum as open, as with every other enum in this contract.

One of: queued, running, paused, completed, failed, canceled

progress object
FieldTypeNotes
items_total integer

What the scan was sized at when it was queued. Products added while it runs are not in it.

items_scanned integer
items_failed integer

Products the analysis could not read. Reported separately rather than folded into items_scanned, because a scan that skipped four products has not looked at them.

regulations array required
FieldTypeNotes
code string required

A regulatory module. Never a path segment — it is a body field or a query filter, so that one catalogue is not uploaded once per module.

Ask GET /regulations which of these your organization may use. Treat this enum as open.

One of: ECGT, GPSR, COSM_655, OMNIBUS

ruleset_version string required

The lexicon version this scan ran at, recorded with the scan rather than looked up afterwards. Without it a re-run is not reproducible.

counts_by_risk object required
FieldTypeNotes
low integer
medium integer
high integer
report_id string or null (uuid)
created_at string (date-time) required
completed_at string or null (date-time)
401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
404 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

POST /api/v1/checks

Check copy that is not in the catalogue

The pre-publication check: send draft copy, get the risk back synchronously, inside three seconds. Nothing is stored — no catalogue item, no content snapshot, no detection — so this is safe to call on copy that may never be published, which is the whole point of it.

Send the fields container exactly as you would send it to POST /catalog/items. It is read the same way, against the same lexicon, so a check and the scan of the same copy agree.

Regulation arrives in the body, never in the path: one catalogue, and regulation is a dimension of the result. Ask GET /regulations which codes your organization may use rather than hardcoding them. A code you have no entitlement for is a 403; a code that does not exist at all is a 422 with unknown_regulation — two different problems with two different fixes.

This operation fails open. Do not block publishing on it.

Every answer here is a 200, including the ones where the analysis did not run. This endpoint sits inside your publish flow, and a compliance tool that can stop a shop shipping a product is a compliance tool that gets removed from the publish flow. So read status, never the status line:

status What ran What to do
complete Both layers Act on findings. An empty list is good news.
partial The lexicon only Act on findings; they are real and citable. Wording that implies a benefit without naming one was not looked for.
unavailable Nothing Publish anyway. findings is empty and means nothing. Retry later, or rely on your next catalogue scan.

warning is present on the last two and carries a stable code beside a sentence for a person. It is deliberately not a problem document: a degraded check is not an error, and wrapping it in one would invite a generic error handler to treat it as a failed request.

ruleset_versions says which lexicon each module actually ran at. Record it beside anything you keep: it is what lets you tell a claim somebody fixed from a rule that changed.

The three-second budget is for the whole operation. When a check outgrows it — a long description against four regulatory modules — this operation will answer 202 with a Location pointing at GET /checks/{id} instead, and nothing else about it changes. That is what the plural collection name is for.

There is no Idempotency-Key. A check writes nothing you can see, so a retry is simply another check.

Request body

application/json, required

FieldTypeNotes
locale string required

An official EU language, optionally with a two-letter region subtag — pl or pl-PL. Both are accepted and stay distinct, because your platform emits one of them and a report that renamed it would not match your export.

The allowlist is all 24 official languages of the Union, not the one language a lexicon exists for today. Send your German copy as de; mislabelling it as pl to get it accepted would have it read against the wrong lexicon, which is worse than not being read at all.

regulations array required

At least one. Ask GET /regulations which codes you may use; a code you have no entitlement for is a 403 and a code that does not exist is a 422.

fields object required

The copy, keyed the way you key a catalogue item's fieldsname, description_long, attributes.material. Nesting is read one level down, exactly as it is on ingest.

Values that are not natural language are skipped rather than refused: a price, an EAN, a hex colour. What comes back on a finding is the key you sent.

One ceiling, and it is real: at most x-substantly-max-characters characters of copy across every field in one call, because the whole operation has three seconds. Over it the answer is a 422 rather than a slow unavailable — "split this" is actionable and "we were too slow" is not.

A product name and description, before publishing

{
    "locale": "pl",
    "regulations": [
        "ECGT"
    ],
    "fields": {
        "name": "Krem nawilzajacy BIO",
        "description_long": "W 100% biodegradowalny, przyjazny dla srodowiska."
    }
}

Responses

StatusMeaning
200

The check ran, or degraded to a warning. Both are this status — read status in the body.

400

The request body is not valid JSON.

401

The key is missing, malformed, revoked or expired.

403

The shop is not verified, the plan does not include the pre-publication check, or the organization is not entitled to one of the regulations it asked for. code separates the first from the other two, and feature separates those.

422

The body cannot be acted on: a missing or unsupported locale, no regulations, no fields, more copy than one check may carry, or a regulation code that names no module (unknown_regulation).

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

200 shape and examples
HeaderDescription
RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/json

FieldTypeNotes
status string required

How much of the analysis ran. This is the field to branch on, not the HTTP status: every answer from POST /checks is a 200, because this endpoint must never be the reason a product cannot go live.

Treat this enum as open in the sense that a client meeting a value it does not know should treat it as unavailable — degraded, and never a reason to block.

One of: complete, partial, unavailable

locale string required

An official EU language, optionally with a two-letter region subtag — pl or pl-PL. Both are accepted and stay distinct, because your platform emits one of them and a report that renamed it would not match your export.

The allowlist is all 24 official languages of the Union, not the one language a lexicon exists for today. Send your German copy as de; mislabelling it as pl to get it accepted would have it read against the wrong lexicon, which is worse than not being read at all.

regulations array required

The modules asked for, in the order asked.

ruleset_versions object required

Regulation code to the lexicon version that actually ran. Empty when nothing ran.

Record it beside anything you keep. A finding is only reproducible against the version that produced it, and it is what tells a claim somebody fixed apart from a rule that changed.

findings array required

Ordered by regulation, then by field, then by position in the text.

An empty list only means something when status is complete. On unavailable it is the absence of an answer, not a clean bill of health.

FieldTypeNotes
regulation string required

A regulatory module. Never a path segment — it is a body field or a query filter, so that one catalogue is not uploaded once per module.

Ask GET /regulations which of these your organization may use. Treat this enum as open.

One of: ECGT, GPSR, COSM_655, OMNIBUS

field string required

The key from your fields, unchanged.

span array required

Start and end offsets into that field's readable text, counted in Unicode code points and not in bytes. Markup is stripped before the copy is read, so the offsets index into what a customer sees.

matched_text string required

The words the span covers, for a client that does not want to slice.

rule_id string or null required

Null together with legal_reference when the classifier found a claim no rule in this lexicon covers. We do not invent a citation.

legal_reference string or null required

The provision this rule implements. Null exactly when rule_id is.

claim_type string required
risk_level string required

How exposed a finding leaves the merchant. Substantly reports risk with a confidence; it does not say a product is non-compliant and it issues no legal opinions.

One of: low, medium, high

confidence number or null required

The classifier's score, or null when layer is lexical — nobody scored it, which is a different statement from a low score. Show that rather than filling the gap with a number.

rationale string or null required

Why the classifier said so. Null on a lexical finding.

suggested_fix string or null required

Advisory, always. Substantly never modifies your content; a person approves a change to copy.

layer string required

lexical — the lexicon matched these exact words. llm — the classifier read them in context and scored them.

One of: lexical, llm

warning object required

Present exactly when status is not complete, and null otherwise.

Not a problem document, deliberately: a degraded check is a success with less in it, and giving it the shape of an error is how a generic error handler ends up blocking a publish.

Both layers ran

{
    "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": "The copy claims biodegradability without naming a standard or a timeframe.",
            "suggested_fix": "Name the standard the claim is substantiated against, or remove it.",
            "layer": "llm"
        }
    ],
    "warning": null
}

Nothing ran — publish anyway

{
    "status": "unavailable",
    "locale": "pl",
    "regulations": [
        "ECGT"
    ],
    "ruleset_versions": [],
    "findings": [],
    "warning": {
        "code": "analysis_unavailable",
        "detail": "This check could not be completed, so no risk has been ruled in or out. Do not block publishing on it — retry the check later, or rely on your next catalogue scan."
    }
}
400 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-json",
    "title": "Malformed JSON",
    "status": 400,
    "code": "invalid_json",
    "detail": "The request body is not valid JSON."
}
401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
403 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

422 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

GET /api/v1/checks/{id} planned

Read a check by id

Reserved, and answering 501 today. It is where the Location of a 202 from POST /checks will point on the day a check outgrows its synchronous budget.

The path is documented now so that day changes nothing for you: a client that already follows Location on a 202 keeps working, and one that does not was never going to. Today the check is synchronous — read the body of the POST.

Parameters

NameInRequiredDescription
id path yes

Responses

StatusMeaning
401

The key is missing, malformed, revoked or expired.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

501

A reserved path that is not served yet. It is a 501 and not a 404 on purpose: "not built" and "wrong URL" are different problems, and an integrator should be able to tell which one they have.

A path that has not yet been reserved in the router answers 404 instead. Do not read either as a permanent answer about the endpoint — x-substantly-status on the operation is what says whether it is built, and it is the field to check.

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}
501 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

GET /api/v1/regulations

Which regulatory modules exist, and which ones you may use

Call this instead of hardcoding regulation codes. Because regulation is a body parameter rather than a path segment, this is how a client learns which values are valid for it — and a customer who buys a module later should not have to redeploy an integration to use it.

Every known code is listed, including ones that are not built yet: those come back with availability: planned and entitled: false rather than being omitted, so you can see what is coming.

availability: available means a scan can run it now. It is the same test POST /scans applies: a module whose ruleset is still being written is reported planned here and answers 422 there, so the two endpoints cannot tell you different things. entitled answers the narrower question — may you scan against it today — and is therefore false for every planned module, whatever your plan includes.

The answer is about the shop your API key belongs to. There is no store parameter: a store checks only itself.

Responses

StatusMeaning
200

The modules, with this organization's entitlements.

401

The key is missing, malformed, revoked or expired.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

200 shape and examples

application/json

FieldTypeNotes
regulations array required
FieldTypeNotes
code string required

A regulatory module. Never a path segment — it is a body field or a query filter, so that one catalogue is not uploaded once per module.

Ask GET /regulations which of these your organization may use. Treat this enum as open.

One of: ECGT, GPSR, COSM_655, OMNIBUS

name string required
jurisdiction string required
applies_from string or null (date) required
availability string required

Whether the module is built. planned codes are listed rather than omitted, so you can see what is coming.

One of: available, planned

entitled boolean required

Whether this organization may scan against it today.

ruleset_version string or null required
locales array required

The languages a scan may actually run in today: those whose measured recall and precision cleared the publication gate, plus any being served under a recorded emergency exception.

Narrower than the languages the ruleset carries citations for. A language that has not been validated is a 422 on POST /api/v1/scans, and listing it here would send you straight into the refusal this endpoint exists to prevent.

locale_readiness array required

Every language the ruleset carries citations for, validated or not, with the measurement behind each verdict.

This is the list that distinguishes "we do not cover German" from "German is measured and not yet validated", and only one of those is worth planning around. locales above is the subset you may scan today.

FieldTypeNotes
locale string required

An official EU language, optionally with a two-letter region subtag — pl or pl-PL. Both are accepted and stay distinct, because your platform emits one of them and a report that renamed it would not match your export.

The allowlist is all 24 official languages of the Union, not the one language a lexicon exists for today. Send your German copy as de; mislabelling it as pl to get it accepted would have it read against the wrong lexicon, which is worse than not being read at all.

stage string required

production — measured against a held-out corpus and above both thresholds. candidate — not validated for this language; scans in it are refused. override — below threshold and released anyway under a recorded exception.

One of: production, candidate, override

recall number or null required

As measured. Null for a language nothing has scored, which is not the same as one scored at zero.

precision number or null required
reason string required

One sentence saying how this language reached this stage — too few labelled examples, a measurement below target, a corpus that has moved since. Written for a person, not parsed.

override object or null required

Present only when this language's stage is an override.

FieldTypeNotes
reason string required
granted_by string required
granted_at string (date) required
expires_at string (date) required

The exception lapses on this date and the language returns to candidate on its own.

override boolean required

Whether any language of this module is being served under a recorded exception — released before its measurement met the thresholds, for a stated reason and for a bounded period.

Findings produced this way are labelled as such in the report footer. The flag is repeated here, out of the per-language list, so a client can check it without walking entries.

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

GET /api/v1/reports/{id}

Fetch the report a scan produced

The evidence document for a completed scan: findings with their legal references, the ruleset_version they were produced at, and the shop's name and domain.

Request application/pdf through Accept for the rendered document, or the default application/json for its structure. Sending no Accept at all — or */*, which most HTTP clients send — gives you the JSON, as everywhere else in this API.

The store name and domain on a report come from the proved domain ownership and never from a field anybody typed. A document asserting a shop's identity is only worth having if that identity was checked.

Rendering is asynchronous. A report is compiled the moment it is asked for and rendered on a queue, so a freshly created one answers with status: queued and a null download_url. Poll status rather than the URL; asking for the PDF before it exists is a 409.

download_url is a signed, short-lived link intended to be given to a person — it opens in the browser session of a signed-in member of the organization the report belongs to. An integration that wants the bytes should ask this endpoint for application/pdf instead, which is authenticated by the same API key as every other call.

Parameters

NameInRequiredDescription
id path yes

Responses

StatusMeaning
200

The report.

401

The key is missing, malformed, revoked or expired.

404

No report with that id belongs to this organization.

409

The PDF was asked for and the report has not finished rendering. The report exists and you are entitled to it; there is simply nothing to send yet.

200 shape and examples

application/json

FieldTypeNotes
id string (uuid) required
scan_id string (uuid) required
store object required

Taken from the proved domain ownership, never from a field anybody typed. A document that asserts whose shop this is has to have checked.

FieldTypeNotes
name string required
domain string required
verification object required

How and when that ownership was proved. It is on the report because the report is evidence: a reader who has to take the shop's identity on trust has nothing to check.

FieldTypeNotes
method string required

One of: dns_txt, well_known_file

proved_at string (date-time) required
status string required

Rendering is asynchronous. Poll this rather than the download URL. failed is terminal — the findings are untouched and the report can be asked for again.

revoked is also terminal, and means something different: the document rendered correctly and the purchase that paid for it was reversed, so it is no longer served. The scan and its findings are untouched. A copy already downloaded is not recalled — it cannot be — but download_url is null from that point on.

One of: queued, rendering, ready, failed, revoked

generated_at string (date-time) required
ruleset_versions array required

Every ruleset version behind a finding in this report, sorted. More than one when a scan spanned a ruleset release. The same list is printed in the footer of every page of the PDF, because a finding nobody can reproduce is a finding nobody can act on.

overridden_ruleset_versions array required

Which of ruleset_versions had not been validated for the language they ran in, and were released under a recorded exception anyway. Empty in every ordinary report, which is the point: a client that files these documents can tell one apart without reading the PDF.

It is the state at the moment the scan ran, not the state today — an exception expires and a passing manifest replaces it, so asking later would answer a question about later.

analysis_versions array

Which build of the analysis produced the findings. Beside ruleset_versions rather than instead of it: a ruleset version says which rules ran, this says which code applied them, and a finding can move between two releases without a single rule changing.

Empty for a scan run before this was recorded — an absent version is stated as absent rather than filled in with the version running now, which is not the one that answered.

download_url string or null (uri)

A signed, short-lived link for a person — it opens in the browser session of a signed-in member of this organization. Null until status is ready. An integration should request application/pdf from this endpoint instead of following it.

download_url_expires_at string or null (date-time)

When the link above stops working. Fetch the report again for a fresh one; the report itself is kept.

sections array required

One entry per regulation the scan's results mention — always, even when there is only one, which today there always is. The shape does not change when the next regulatory module lands.

FieldTypeNotes
regulation string required

A regulatory module. Never a path segment — it is a body field or a query filter, so that one catalogue is not uploaded once per module.

Ask GET /regulations which of these your organization may use. Treat this enum as open.

One of: ECGT, GPSR, COSM_655, OMNIBUS

items integer required
detections integer required
ruleset_versions array required

application/pdf

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
404 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

409 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

GET /api/v1/detections planned

Findings, filtered

Planned. One detection is one hit in one content snapshot. Filter by regulation and risk as query parameters — note that regulation is a filter here, not a path segment, for the same reason it is a body field on a scan.

Parameters

NameInRequiredDescription
regulation query no

Restrict to findings from one regulatory module.

risk query no

Responses

StatusMeaning
200

The findings.

401

The key is missing, malformed, revoked or expired.

501

A reserved path that is not served yet. It is a 501 and not a 404 on purpose: "not built" and "wrong URL" are different problems, and an integrator should be able to tell which one they have.

A path that has not yet been reserved in the router answers 404 instead. Do not read either as a permanent answer about the endpoint — x-substantly-status on the operation is what says whether it is built, and it is the field to check.

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
501 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

GET /api/v1/claims

The durable claim register

A claim is the lasting business object — "the biodegradability claim on product X's packaging" — that survives content edits and accumulates evidence. It is not the same thing as a detection, which is one hit in one content snapshot and dies with that snapshot: reword the sentence and the next scan produces a different detection against the same claim.

The list is the shop your key belongs to, and only that shop. A key is issued to one store, so there is no widening: a store filter naming another of your own shops narrows the list to nothing rather than answering 403, and a claim of another organization is simply not here.

regulation is a filter, never a path segment. There is no /api/v1/ecgt/claims. You have one register, and the regulation a claim was opened under is a dimension of the row.

Paginate with updated_since and cursor. Rows come back oldest change first, so a nightly job asks for everything since its last run and follows next_cursor until it is null. A claim changed while you are paging reappears later in the sequence rather than being skipped — the sync is at-least-once, and applying the same claim twice is harmless.

Evidence is listed, never served. Each document comes back as a reference — the merchant's own filename, the stored size, the SHA-256 of the bytes and when it was filed — with no URL. A link that worked outside a browser session is a different security decision from the one this endpoint makes.

Parameters

NameInRequiredDescription
regulation query no

Restrict to claims opened under one regulatory module. A code that names a module you have never scanned against narrows the list to nothing; a string that is not a code at all is refused with unknown_regulation.

status query no

One or more statuses, comma-separated: ?status=open,unsubstantiated.

risk query no

One or more risk levels, comma-separated.

store query no

The shop, as a redundant assertion rather than a selector. Your key already names one store; sending a different one — including another shop of your own organization — returns an empty list, because one key never reaches two shops.

assignee query no

A member's id, or none for the claims nobody has picked up. The unassigned queue is a real answer, which is why it is a value rather than an absent parameter.

updated_since query no

Claims changed at or after this instant, RFC 3339. What a nightly sync asks: "what has moved since I last looked". Use the timestamp of your last successful run rather than the newest updated_at you saw, so a claim changed mid-page is not missed.

cursor query no

The next_cursor of the previous page, opaque. Do not construct or parse one; its contents are not part of this contract.

limit query no

Claims per page.

Responses

StatusMeaning
200

One page of the register.

401

The key is missing, malformed, revoked or expired.

403

The shop has not proved it owns its domain (store_not_verified), or the plan does not carry the claim register (entitlement_required). code separates them, and only the second is fixed by buying something.

422

A filter value this endpoint cannot read: a status, risk or assignee that is not one of the documented values, an updated_since that is not a timestamp, a cursor from somewhere else, or a regulation that is not a code (unknown_regulation).

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

200 shape and examples

application/json

FieldTypeNotes
claims array required
FieldTypeNotes
id string (uuid) required
store_id string (uuid) required

The shop, which is always the one your key was issued to.

external_id string required

Your own identifier for the product, exactly as you sent it to the catalogue.

claim_type string required

What kind of claim the analysis found, as the lexicon names it. Not an enum: the lexicon is versioned YAML that grows with the law, so a claim type arrives without a release of this API.

regulation string required

A regulatory module. Never a path segment — it is a body field or a query filter, so that one catalogue is not uploaded once per module.

Ask GET /regulations which of these your organization may use. Treat this enum as open.

One of: ECGT, GPSR, COSM_655, OMNIBUS

status string required

How far a claim has got through its evidence library. The vocabulary is the directive's own — 2024/825 asks whether a claim is substantiated, meaning whether documentation exists — and not a verdict on the shop.

substantiated means "documentation exists and a human accepted it". It does not mean the wording is lawful, and nothing in this API says that.

Treat this enum as open.

One of: open, substantiated, unsubstantiated, retired

risk object required

The highest risk among the findings filed under this claim. Null until one has been. It never falls on its own: a rewording that drops the worst phrase leaves the claim where it was until somebody reviews it.

assignee object or null required

The person who owns getting this documented, or null when nobody has picked it up.

FieldTypeNotes
id string (uuid) required
email string (email) required
due_at string or null (date) required

The merchant's own working date, YYYY-MM-DD. Nothing in 2024/825 sets it and nothing here treats a missed one as a finding.

reviewed boolean required

Whether a person has looked at this claim. Its own fact, not a reading of status — a claim can be triaged and still open.

reviewed_at string or null (date-time) required
detection_count integer required

How many findings are filed under this claim, across snapshots and ruleset versions.

evidence_count integer required
evidence array required
FieldTypeNotes
id string (uuid) required
filename string or null required

As the merchant's browser sent it. Null for evidence with no file — a certificate whose paperwork lives with the issuer.

size_bytes integer or null required
checksum string or null required

SHA-256 of the stored bytes, lower-case hex.

attached_at string (date-time) required
version integer required

What the row is at. Increments on every change to the claim.

etag string required

version in the form If-Match wants. Send it back verbatim on your next patch of this claim rather than assembling it yourself.

created_at string (date-time) required
updated_at string (date-time) required

The instant the claim last changed. updated_since filters on this, and pages are ordered by it.

next_cursor string or null required

Pass as cursor for the next page. Null when this is the last one — which is the only condition to stop on, because a full page is not a promise that another exists.

401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
403 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

422 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

PATCH /api/v1/claims/{id}

Move a claim through the workflow

A partial update over exactly four fields: status (with an optional note), assignee, due_at and reviewed. Everything else about a claim — its claim_type, its regulation, its risk — is a fact the analysis produced rather than a field, and naming one here is refused rather than ignored.

Every field goes through the same handler the console screen uses, so this endpoint can do nothing the person working the register cannot, and meets the same refusals in the same order. A claim with an empty evidence library cannot be recorded as substantiated, and withdrawing a documented conclusion needs a note saying why.

If-Match is required. Send the etag of the claim you read. A stale one is 412 and writes nothing — which is the whole point: a nightly job that read a claim at 02:00 and patches it at 02:40 must not silently overwrite what a person did at 02:20.

A body that changes two fields runs two handlers, in the order status, assignee, due_at, reviewed, and writes a history row for each. If any of them refuses, the request fails and nothing is written: a partial success answered with 200 is a client that believes both halves landed.

No operation in this API ever edits a merchant's own copy. Suggested wording is returned for a person to approve and apply; Substantly does not write to a shop.

Parameters

NameInRequiredDescription
id path yes
If-Match header yes

The version of the claim you are patching, exactly as its etag field gave it to you — a quoted integer, "4".

Required rather than optional. An optional precondition is one no client sends, and requiring it costs you one header you already have from the read you just made. * is refused: it says the claim exists, which is not what you need to assert.

A value that is not the claim's current version is 412 and writes nothing.

Request body

application/json, required

FieldTypeNotes
status string

How far a claim has got through its evidence library. The vocabulary is the directive's own — 2024/825 asks whether a claim is substantiated, meaning whether documentation exists — and not a verdict on the shop.

substantiated means "documentation exists and a human accepted it". It does not mean the wording is lawful, and nothing in this API says that.

Treat this enum as open.

One of: open, substantiated, unsubstantiated, retired

note string

Why. Required when leaving substantiated for unsubstantiated, optional on any other move, and refused without a status — a note about nothing is not a claim's history.

assignee string or null (uuid)

A member of your organization, by id, or null to take the claim off everybody. Somebody outside your organization is refused: there is no reading of "assign" that spans two customers.

due_at string or null (date)

The merchant's own working date, YYYY-MM-DD, or null to clear it.

reviewed boolean

true records that a person has looked at the claim. There is no false: un-reviewing is not a thing the register does, and a claim that comes back is a decision about resolution rather than a button.

One of: 1

Record a documented conclusion

{
    "status": "substantiated",
    "note": "Certyfikat OK Compost TÜV, ważny do 2027-03-31."
}

Hand the claim to a colleague, with a date

{
    "assignee": "018f3c2a-0000-7000-8000-000000000010",
    "due_at": "2026-09-20"
}

Responses

StatusMeaning
200

The claim as it now stands.

400

The request body is not valid JSON.

401

The key is missing, malformed, revoked or expired.

403

The shop has not proved it owns its domain (store_not_verified), or the plan does not carry the claim register (entitlement_required).

404

No claim with that identifier belongs to the shop this key was issued to (claim_not_found).

412

The If-Match is not the version the claim is at (claim_changed). Nothing was written. Read the claim again and decide whether your change still applies.

422

The body cannot be acted on, or the workflow will not make the move: a field this endpoint does not set, no field it does, a value one of them cannot take, an assignee outside your organization (invalid_request), or a transition the register refuses (evidence_required, note_required, illegal_transition). Nothing was written.

428

No usable If-Match (if_match_required). The precondition is required, not optional.

429

This key's ceiling for the current minute is spent. Retry-After says when to come back.

The limit is per key, not per organization: a second key issued for a nightly batch job gets its own budget, and a sbl_test_ key can never spend a sbl_live_ one's.

200 shape and examples
HeaderDescription
ETag

The claim's new version. Send it as If-Match on your next patch of this claim.

application/json

FieldTypeNotes
id string (uuid) required
store_id string (uuid) required

The shop, which is always the one your key was issued to.

external_id string required

Your own identifier for the product, exactly as you sent it to the catalogue.

claim_type string required

What kind of claim the analysis found, as the lexicon names it. Not an enum: the lexicon is versioned YAML that grows with the law, so a claim type arrives without a release of this API.

regulation string required

A regulatory module. Never a path segment — it is a body field or a query filter, so that one catalogue is not uploaded once per module.

Ask GET /regulations which of these your organization may use. Treat this enum as open.

One of: ECGT, GPSR, COSM_655, OMNIBUS

status string required

How far a claim has got through its evidence library. The vocabulary is the directive's own — 2024/825 asks whether a claim is substantiated, meaning whether documentation exists — and not a verdict on the shop.

substantiated means "documentation exists and a human accepted it". It does not mean the wording is lawful, and nothing in this API says that.

Treat this enum as open.

One of: open, substantiated, unsubstantiated, retired

risk object required

The highest risk among the findings filed under this claim. Null until one has been. It never falls on its own: a rewording that drops the worst phrase leaves the claim where it was until somebody reviews it.

assignee object or null required

The person who owns getting this documented, or null when nobody has picked it up.

FieldTypeNotes
id string (uuid) required
email string (email) required
due_at string or null (date) required

The merchant's own working date, YYYY-MM-DD. Nothing in 2024/825 sets it and nothing here treats a missed one as a finding.

reviewed boolean required

Whether a person has looked at this claim. Its own fact, not a reading of status — a claim can be triaged and still open.

reviewed_at string or null (date-time) required
detection_count integer required

How many findings are filed under this claim, across snapshots and ruleset versions.

evidence_count integer required
evidence array required
FieldTypeNotes
id string (uuid) required
filename string or null required

As the merchant's browser sent it. Null for evidence with no file — a certificate whose paperwork lives with the issuer.

size_bytes integer or null required
checksum string or null required

SHA-256 of the stored bytes, lower-case hex.

attached_at string (date-time) required
version integer required

What the row is at. Increments on every change to the claim.

etag string required

version in the form If-Match wants. Send it back verbatim on your next patch of this claim rather than assembling it yourself.

created_at string (date-time) required
updated_at string (date-time) required

The instant the claim last changed. updated_since filters on this, and pages are ordered by it.

400 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-json",
    "title": "Malformed JSON",
    "status": 400,
    "code": "invalid_json",
    "detail": "The request body is not valid JSON."
}
401 shape and examples
HeaderDescription
WWW-Authenticate

Always Bearer realm="Substantly API".

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/invalid-api-key",
    "title": "Unauthorized",
    "status": 401,
    "code": "invalid_api_key",
    "detail": "The API key is missing, malformed, revoked or expired."
}
403 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

404 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

412 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

422 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

428 shape and examples

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

429 shape and examples
HeaderDescription
Retry-After

Seconds to wait before retrying. Present on 429 only.

RateLimit-Limit

Requests permitted in the current window for this key.

RateLimit-Remaining

Requests left in the current window. Watch this rather than waiting to be refused.

RateLimit-Reset

Seconds until the window rolls over. Never zero.

application/problem+json

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code string required

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

detail string required

What went wrong, in a sentence. Prose — do not parse it.

{
    "type": "https://substantly.eu/problems/rate-limit-exceeded",
    "title": "Too many requests",
    "status": 429,
    "code": "rate_limit_exceeded",
    "detail": "This API key is limited to 120 requests per minute. Retry in 37 seconds."
}

Webhooks

Requests we make to you when something happens. Every delivery is signed, retried on a published schedule, and recorded attempt by attempt in a log you can read on the endpoint's own page.

What arrives

A POST with a application/json body, from Substantly-Webhooks/1.0, carrying these headers on top of the usual ones.

Substantly-Signature The signature below. Verify it before you trust the body.
Substantly-Event-Id The envelope's `id`, repeated in a header so you can deduplicate without parsing. Identical on every attempt at the same event.
Substantly-Event-Type The event type, so you can route before you parse.
Substantly-Delivery-Attempt Which attempt this is, from 1. The only header that differs between attempts at one event: seeing `4` means we have been trying for an hour and your acknowledgement is not reaching us, which is a different problem from your handler throwing.

We do not follow redirects. A Location from your endpoint is a second address chosen by whoever controls that endpoint, so a 3xx is recorded as a failed attempt rather than followed. Configure the final URL.

The endpoint's host name is resolved and checked immediately before every delivery, and the connection is pinned to the address that was checked. A name that starts resolving to a private address is refused at send time even though it was accepted when you configured it — the attempt appears in your delivery log as `blocked`, and no connection is opened.

Verifying a delivery

Every delivery carries a Substantly-Signature header: a timestamp and an HMAC-SHA256 over <timestamp>.<raw request body>, keyed with your endpoint's secret.

Substantly-Signature: t=1789279200,v1=c608925722f4e88eccf2b85a5333dee68be41adcb5544f595fb23dc77a4ddad3
  1. Read the header and split it on , then =. You want t and v1.
  2. Compute HMAC-SHA256(key = your endpoint secret, message = t + "." + raw request body) and compare it to v1 in constant time.
  3. Reject the delivery if t is more than tolerance-seconds away from your own clock, in either direction.

Verify against the raw bytes of the body, before you parse them. A receiver that re-serialises the JSON and signs that is verifying its own serialiser's output — key order, unicode escaping and number formatting all differ — and the check either fails constantly or gets "fixed" into uselessness.

The timestamp is inside the signed material rather than beside it. A signature over the body alone is valid for ever, so one captured delivery could be replayed at any point in the future and would be indistinguishable from a fresh one. Step 3 is what bounds that, and it is only meaningful because step 2 covers t.

v1 names the scheme. If a second algorithm is ever introduced it will arrive as an additional key in the same header, so that nobody has a flag day.

Reject a delivery whose timestamp is more than 300 seconds from your own clock, in either direction. That window is what makes a captured delivery worth something for minutes rather than for ever.

Retries

Deliveries are at-least-once. Your receiver has to be idempotent.

This is the one property of the transport that changes how you write your handler, which is why it is stated here rather than discovered later. A delivery whose connection dies after you have committed but before we read your response is, from our side, indistinguishable from one that never arrived — so we retry it. Key your handler on the envelope's id, which is stable across every attempt at the same event, and treat a repeat as a no-op.

Do not key on "have I seen this scan finish": the same scan can legitimately produce a scan.completed and, after a rescan, another one. The event id is the identity; the scan is not.

After the last attempt the event is given up on and not retried again. Reconcile through the API rather than waiting: every payload carries the identifier you need to fetch the real state.

Guarantee at-least-once
Attempts 6, the first one included
Gaps between attempts 60s, 300s, 1500s, 7500s, 21600s
Your time to answer 10s

When we stop trying

An endpoint that stops answering is switched off, and its owners are emailed. After five consecutive events that used every attempt and never got through, we stop sending to that endpoint until somebody re-enables it in the web console. That is thirty attempts across roughly two days, and the counter is reset by the first delivery that succeeds — so a receiver that fails one event in three is never switched off.

Events recorded while an endpoint is off are not queued up for it. They are still in your account and still readable through the API, and re-enabling the endpoint resumes delivery for new events. If you need one of the missed ones, the endpoint's delivery log has a button that sends it again.

The envelope

The same five fields on every delivery, whatever the type. A receiver that reads id, type and store_id without looking inside data keeps working for every event type added later.

FieldTypeNotes
id string required

Stable across every delivery attempt of the same event, and the key your handler should be idempotent on. It sorts into the order the events happened, so "what did I miss" is answerable.

type string required

Every event Substantly will send, including the two nothing raises yet. x-substantly-raised on the webhook says which is which.

Treat this enum as open in the same way as ErrorCode: a future release may add a member. A receiver that ignores a type it does not handle keeps working; one that throws on an unknown type does not.

One of: scan.completed, scan.failed, certificate.expiring, ruleset.updated, claim.status_changed

created_at string (date-time) required

RFC 3339, UTC, seconds precision.

store_id string (uuid) required

The shop the event is about. On the envelope rather than inside each payload because every event this product raises is about one shop.

data object required

The payload for this type. Its fields are fixed per type and all of them are always present.

Events

An event marked planned has its envelope and payload fixed here but nothing raises it yet. Subscribing to one today is safe and does nothing.

scan.completed

A catalogue scan finished

Sent when a scan reaches the end of the catalogue it was given. There are results to fetch.

detections is a count and nothing more. The findings themselves are not in the payload, and that is deliberate: a webhook body travels to a server we do not control and sits in its logs, and "how many findings" is a far smaller disclosure than "which sentences in whose product copy". Fetch them with GET /api/v1/detections using the key you already have.

The data object. The envelope around it is the same for every event.

FieldTypeNotes
scan_id string (uuid) required

Fetch the findings with GET /api/v1/detections?scan_id=….

detections integer required

How many findings the scan produced. A count, never the findings themselves.

regulations array required

The regulation codes this scan was run for.

Example delivery

{
    "id": "evt_0192c7a000007000800000000000002a",
    "type": "scan.completed",
    "created_at": "2026-09-14T10:00:00Z",
    "store_id": "0192c7a0-0000-7000-8000-000000000001",
    "data": {
        "scan_id": "0192c7a0-0000-7000-8000-000000000002",
        "detections": 47,
        "regulations": [
            "ECGT"
        ]
    }
}

scan.failed

A catalogue scan gave up

Sent when a scan has exhausted its retries and will not finish. Never sent while a retry is still coming: an integration told this and then handed results has been told something untrue.

There is no reason. Why a scan stopped is a fact about our infrastructure, and it belongs in our incident channel rather than in your log aggregator. What you can act on is how far it got.

The data object. The envelope around it is the same for every event.

FieldTypeNotes
scan_id string (uuid) required
regulations array required
items_scanned integer required

Products the scan got through before it gave up.

items_failed integer required

certificate.expiring

A certificate backing a claim is about to expire

Sent when a certificate filed against a claim reaches one of four moments: ninety, thirty and seven days before it stops being valid, and again on the day it does.

At most four events per certificate, one per window. days_remaining says which window this is — 90, 30, 7 or 0 — and it is the window rather than the exact arithmetic, so a document that moves by a day does not turn into a different kind of event. A daily pass records each window as it sends it, so a re-run, a restarted worker or a redelivery produces nothing further.

Windows that have already passed are not sent late. A certificate first filed twenty days before it expires gets the thirty-day event and never a backdated ninety-day one.

claim_id is the claim the document was filed against, which is where the renewal is actually worked: fetch it with GET /api/v1/claims/{id}.

Nothing about the claim changes. A certificate expiring is a fact about a document, not a decision about a claim — the status stays where the merchant put it, and the register marks the claim as resting on lapsed support until somebody files a renewal or moves it.

Included on every paid plan. Organizations without it are not sent this event; their register still shows them that the document has expired.

The data object. The envelope around it is the same for every event.

FieldTypeNotes
claim_id string (uuid) required

The claim this certificate was filed against.

certificate_id string (uuid) required
expires_at string (date-time) required

Midnight UTC of the last day the certificate is valid. It is valid through that day.

days_remaining integer required

Which of the four windows this is, not the exact remainder. 0 is the day it stops being valid.

One of: 90, 30, 7, 0

ruleset.updated

A regulation's ruleset moved to a new published version, and your catalogue has been re-scanned against it

The wording recognised for a regulation has changed, this shop has been re-scanned against the new lexicon, and the payload says what that changed.

It arrives when the rescan finishes, not when the version ships. Told at publication time you would fetch a detection list that has not moved yet; told twice — once for the announcement and once for the result — you would have to work out which was which. So there is one event per shop per publication, and by the time it reaches you the findings behind it are already queryable.

new_findings counts detections this version produced that no earlier version of the same regulation's lexicon had found on the same product, rule and field. resolved_findings counts the reverse — wording an earlier version found that this one no longer does — and is a count only: what a phrase disappearing means for a claim is the claim's own stale flag, which is where a person acts on it.

previous_ruleset_version is an empty string when there was no earlier published version of that module. It is never absent: no field in any payload here is optional.

Rescanning is included on every paid plan and draws on none of your scan allowance — we changed the rules, so the scan is ours. Free organizations are not re-scanned and receive this event for no shop.

The data object. The envelope around it is the same for every event.

FieldTypeNotes
regulation string required
ruleset_version string required
previous_ruleset_version string required

What the counts are measured against. An empty string when this is the first published version of the module — never absent.

items_scanned integer required

How many catalogue items the rescan covered.

new_findings integer required

Detections this version produced that no earlier version of this regulation's lexicon had found on the same product, rule and field.

resolved_findings integer required

Findings an earlier version had on those products that this one did not produce. A count, not a list: what is being counted is precisely what is no longer there.

Example delivery

{
    "id": "evt_0192c7a000007000800000000000004b",
    "type": "ruleset.updated",
    "created_at": "2026-10-01T09:12:00Z",
    "store_id": "0192c7a0-0000-7000-8000-000000000001",
    "data": {
        "regulation": "ECGT",
        "ruleset_version": "2026.10.1",
        "previous_ruleset_version": "2026.09.1",
        "items_scanned": 212,
        "new_findings": 7,
        "resolved_findings": 2
    }
}

claim.status_changed

A claim moved through the review workflow

A claim in the register moved between the four states of the approval workflow: somebody accepted the documentation filed against it, or withdrew that conclusion, or retired a wording that is no longer used — or a scan found a retired claim being made again and reopened it.

substantiated means documentation exists and a person accepted it. It is not a statement that the claim is lawful, and no event here is one.

The data object. The envelope around it is the same for every event.

FieldTypeNotes
claim_id string (uuid) required
previous_status string required
status string required

Error codes

Every error is application/problem+json (RFC 9457). Branch on codetype is a URL that may be re-hosted, title and detail are prose that will be rewritten and translated, and status is too coarse to act on.

Request errors

The code of a problem document.

CodeStatusWhenWhat to do
invalid_json 400

The request body is not valid JSON.

Fix the serialiser. Nothing was read, so nothing was stored.

invalid_request 422

The envelope is wrong for the endpoint: no items array on an ingest, or an empty one; no regulations on a scan, or a scope that is not the shape this document describes; a claim patch that names a field this API does not set, names none of the four it does, carries a value one of them cannot take, or names an assignee who is not a member of your organization; a query filter whose value is not one this document lists.

Send {"items": [...]} with at least one item, or {"regulations": [...]} with at least one code. An empty batch is refused rather than answered with an empty success, because it is always a caller bug — a filter that matched nothing, a file that failed to parse — and a 200 is how that bug reaches production unnoticed. A malformed scope is refused for the same reason inverted: ignoring it would scan the whole catalogue, which on the Free plan is the organization's only scan. On a claim, risk and claim_type are refused rather than ignored because they are facts the analysis produced: a request that quietly dropped them would look to the client exactly like one that applied them.

batch_too_large 422

More items than one call may carry.

Split the batch. The ceiling is in the limit member of the response, so you do not have to parse it out of a sentence. Nothing in the refused request was stored.

sync_not_found 404

No sync session with that identifier belongs to this store. A malformed identifier answers the same way.

Check the identifier — the one POST /api/v1/syncs returned, or the one in the Location header it set. A session that never existed, one belonging to another shop and a string that is not a UUID all give the same answer on purpose: a 403 would confirm the id exists, which is a way of learning that a competitor is mid-sync.

sync_already_open 409

POST /api/v1/syncs while this store already has an open session. The detail names it and says when it expires.

Complete the open session, or wait for it to expire 24 hours after it was opened. Two open sessions would each mark the other's batches absent, so there is deliberately no way to have both.

sync_expired 409

Completing a session that has been open for more than 24 hours. It marked nothing and it never will.

Open a new session and push the catalogue again. The expiry is what stops a crashed integration's week-old session from being closed by a retry and removing every product added since.

unknown_sync 422

A batch whose sync_id is not this store's open session — it was never opened, it has been completed, it expired, or it belongs to somebody else.

Open a session and use the id it returns. Nothing in the refused batch was stored, and an Idempotency-Key it carried is released: an integration that believes it is syncing into a session that is gone should learn it now, rather than after the session it does close marks every product of the batches it lost as removed.

invalid_api_key 401

The key is missing, malformed, revoked or expired.

Check the Authorization: Bearer header. The same answer is given whether the key is wrong or absent, so this does not tell you which.

organization_locked 403

The key is valid, and the organization it belongs to has asked to be deleted. Until the grace period ends — or an owner restores the account from the link they were emailed — it answers no API call at all.

Nothing on the client side. An owner reopens the account from the restore link in the confirmation email, or lets the deletion complete. A key that gets this answer is not wrong; it will work again the moment the account is restored.

store_not_verified 403

The shop has not proved it owns its domain, or the proof stopped resolving. store.verification_status separates the two.

Finish verification in the console at /app/stores, then send the same batch again. There is no plan or flag that lifts this.

catalog_item_not_found 404

No item with that identifier exists in this store.

Check the identifier. An item that never existed and one belonging to another organization give the same answer on purpose.

scan_not_found 404

No scan with that identifier belongs to this organization. A malformed identifier answers the same way.

Check the identifier — the one POST /api/v1/scans returned, or the one in the Location header it set. A scan that never existed, one belonging to another organization and a string that is not a UUID all give the same answer on purpose: a 403 would confirm the id exists, which is a way of learning that a competitor has run a scan.

report_not_found 404

No report with that identifier belongs to this organization.

Check the identifier. A report that never existed and one belonging to another organization give the same answer on purpose — a 403 would confirm that a competitor has run a scan.

report_not_ready 409

The PDF was asked for and the report has not finished rendering. Rendering is a queued job, so a report asked for a second ago has no document yet.

Poll the JSON representation and read status. It is ready when there are bytes to fetch. Do not retry on a schedule tied to the link — the report, not the URL, is what you are waiting for.

claim_not_found 404

No claim with that identifier belongs to the shop this key was issued to.

Check the identifier against GET /api/v1/claims, which lists exactly the claims this key may act on. A claim of another organization, a claim of another shop of the same organization, and an identifier that is not a UUID all answer this way on purpose: a 403 would confirm the id exists, and confirming it is how somebody learns which of a competitor's products has been asked to substantiate a claim.

evidence_required 422

status: "substantiated" on a claim whose evidence library is empty.

File the document first, then send the status. substantiated means "documentation exists and a human accepted it" and never "this is lawful", so it may not be recorded against a library with nothing in it. Evidence is attached in the console at the claim; an operation for it over this API is not built yet. Nothing was written.

note_required 422

Leaving substantiated for unsubstantiated with no note.

Send note beside status. Withdrawing a documented conclusion is the one transition an auditor asks about, and the sentence is what the trail records. Nothing was written.

illegal_transition 422

The workflow has no move from the status the claim is in to the one you asked for. A retired claim goes back to open and nowhere else.

Read status on the claim and ask for a status the workflow can reach from it. A retired claim that is being made again is a new review rather than a resumed one, so it cannot jump straight to a conclusion.

claim_changed 412

The If-Match you sent is not the version the claim is at. Somebody changed it after you read it — a person working the register, or another integration.

Read the claim again, decide whether your change still applies to what it now says, and send it with the etag from that read. Nothing was written, including the fields of a multi-field patch that would have been accepted on their own.

if_match_required 428

A PATCH arrived with no If-Match header, or with one that is not an ETag this API issued — * included.

Send If-Match with the etag of the claim you read. It is required rather than optional because an optional precondition is one no client sends, and the first nightly job to overwrite a merchant's own status change would be right to ask why the API allowed it. * is refused for the same reason: it asserts nothing about what you read.

idempotency_key_conflict 409

This Idempotency-Key already settled a different request body.

Use a new key. Reusing it would silently drop one of the two batches.

idempotency_key_in_progress 409

An earlier request with this key is still running.

Wait and retry the same request. The batch is not lost.

entitlement_required 403

The plan the organization is on does not carry what the request asked for, or an allowance it caps has run out.

Buy what the upgrade member names, then retry the identical request. Nothing about the request changes the answer, so retrying it unchanged before that will be refused identically. feature or limit says which gate closed — exactly one of the two is present. The refusal is decided in the domain rather than at the edge, so every gated action answers with this code whatever it is reached through: the catalogue API, the console screens, a queue consumer or the CLI. Endpoints added later inherit it rather than minting a code of their own.

unknown_regulation 422

A regulations entry names no module this deployment can run — it was never built here, or it has no published ruleset behind it — or is not a usable code at all. A regulation filter reaches the last of those: a string that is not a code.

Call GET /api/v1/regulations and send a code from it. This is not the same refusal as a module you have no entitlement for — that one is a 403 with entitlement_required and is fixed by buying it, while this one is fixed by sending a code that exists. The three cases it covers are one answer because they have one fix, and because which of them applies is a fact about our deployment rather than about your request. Filtering is the one place a well-formed code is not refused: a regulation that names a module you have never scanned against narrows the register to nothing and says so with an empty list, because a filter matching no rows is an answer rather than an error.

not_implemented 501

A path that is reserved in this contract and not served yet. Every operation marked x-substantly-status: planned or reserved answers this today; the ones marked implemented never do.

Read x-substantly-status on the operation rather than treating this as a permanent answer about the endpoint. A 501 and a 404 are different problems: "not built yet" against "wrong URL", and this API answers them differently on purpose so you can tell which one you have.

not_found 404

No endpoint is served at that path at all — as opposed to an endpoint that exists and holds nothing under the identifier you asked for, which answers a code naming the resource (catalog_item_not_found, report_not_found).

Check the path against this document. A reserved path answers 501, not this, so a 404 means the URL is not part of the API rather than not built yet — usually a typo or a missing /api/v1 prefix.

pairing_not_claimable 404

A pairing code cannot be turned into a credential. It was never issued, it has expired, it has already been redeemed, or the claim token presented with it does not match.

Start a new pairing and send the merchant through the approval link again. The four reasons are deliberately one answer: telling a caller which of them it hit would tell somebody guessing at codes which half of the guess was right. This code belongs to the plugin channel, whose exchange is specified in docs/plugins/CONTRACT.md; an integration built against this document will never see it.

method_not_allowed 405

The path is served, but not for the method you used.

Use one of the methods in the Allow header of the response. The catalogue takes POST for a bulk upsert and GET for a single item; neither is a PUT.

rate_limit_exceeded 429

This key's ceiling for the current minute is spent.

Wait Retry-After seconds. Better, watch RateLimit-Remaining on the successes and slow down before you are refused.

Item errors

Reported per row in items[].error.code on a 207. One unusable product is not a failed request, so these never appear as a problem document.

CodeStatusWhenWhat to do
invalid_item 207

The item is not a JSON object, or published is not a boolean.

Fix that row. The rest of the batch was stored.

invalid_external_id 207

external_id is missing, empty, not a string, or too long.

Read index to find the row — external_id is null in the result exactly when it was the field that could not be read.

unsupported_locale 207

locale names no official EU language.

Send the language, optionally with a region: pl or pl-PL. Do not relabel copy to get it accepted; content read against the wrong lexicon produces findings nobody can defend.

invalid_fields 207

fields is missing or is not a JSON object.

Send the product content as an object keyed by field name.

invalid_canonical_url 207

The URL is unusable, too long, or on a domain this store has not proved it owns.

Point it at the verified domain or a subdomain of it. The message names the domain the store did prove, which is the one to compare your export against.

duplicate_external_id 207

The same external_id appears more than once in one batch.

De-duplicate the export. The last occurrence is the one stored; every earlier one is reported here so a broken export is found rather than silently half-applied.

Schemas

ErrorCode

The stable identifier a client branches on. It does not change when the wording of title or detail does.

Treat this enum as open: a future release may add a member, and a client that treats an unknown code as "an error I do not handle yet" keeps working. It will never change the meaning of one already listed.

One of: invalid_json, invalid_request, batch_too_large, invalid_api_key, organization_locked, store_not_verified, catalog_item_not_found, scan_not_found, report_not_found, report_not_ready, claim_not_found, evidence_required, note_required, illegal_transition, claim_changed, if_match_required, idempotency_key_conflict, idempotency_key_in_progress, rate_limit_exceeded, entitlement_required, unknown_regulation, not_implemented, not_found, method_not_allowed, pairing_not_claimable, sync_not_found, sync_already_open, sync_expired, unknown_sync

ItemErrorCode

Why one item in a batch was not stored. These appear in items[].error.code on a 207, never as the code of a problem document — an unusable item is not a failed request.

One of: invalid_item, invalid_external_id, unsupported_locale, invalid_fields, invalid_canonical_url, duplicate_external_id

Problem

RFC 9457. Every error in this API has this shape, and some carry extra members beside these five.

FieldTypeNotes
type string (uri) required

A URL naming the problem. It may be re-hosted; do not branch on it.

title string required

A short summary. Prose — it will be rewritten and translated.

status integer required

The HTTP status, repeated in the body.

code object required
detail string required

What went wrong, in a sentence. Prose — do not parse it.

StoreNotVerifiedProblem

FieldTypeNotes
store object required
FieldTypeNotes
id string (uuid) required
verification_status string required

unverified — ownership was never proved. verification_lapsed — it was proved and stopped resolving; data is kept and scans are paused for 14 days.

One of: unverified, verification_lapsed

EntitlementRequiredProblem

The request was understood and the caller is who they say they are. What is missing is a purchase.

403 rather than 402: the same status the rest of this API uses when a request is refused for who is making it rather than for how it was written, and the reason is in feature and upgrade rather than in the status line. Nothing about the request changes the answer, so retrying it unchanged will be refused again — buy what upgrade names, then retry.

feature is the stable identifier of what was refused; limit is present instead when a ceiling rather than a feature was reached. Exactly one of the two appears.

FieldTypeNotes
feature string

What the plan does not carry. Absent when a limit was reached rather than a feature refused.

One of: pre_publish_check, certificate_monitoring, rescan, csv_export, pdf_report

limit object

The ceiling that was reached. Absent when a feature was refused.

FieldTypeNotes
key string required

Which allowance ran out.

One of: scan_jobs, daily_tokens, catalog_items

value integer required

How many the current plan allows.

upgrade object required

What to buy so the same request succeeds. This is the documented upgrade path: code identifies it, url is where a person completes the purchase, and both are stable while the thing is sold. null when nothing currently on sale would lift the refusal — in which case the answer is not to retry but to talk to us.

FieldTypeNotes
code string required

The product or plan code that carries the refused feature.

name string required

Its name, as a person would read it. Prose.

url string (uri) required

Where a person buys it.

BatchProblem

FieldTypeNotes
limit integer

The ceiling that was exceeded. Present on batch_too_large.

Locale

An official EU language, optionally with a two-letter region subtag — pl or pl-PL. Both are accepted and stay distinct, because your platform emits one of them and a report that renamed it would not match your export.

The allowlist is all 24 official languages of the Union, not the one language a lexicon exists for today. Send your German copy as de; mislabelling it as pl to get it accepted would have it read against the wrong lexicon, which is worse than not being read at all.

CompleteSyncRequest

FieldTypeNotes
status string required

The only transition this resource has.

One of: complete

Sync

FieldTypeNotes
id string (uuid) required
status string required

open accepts batches; completed has decided what left the catalogue; expired decided nothing and never will. Treat this enum as open, as with every other enum in this contract.

One of: open, completed, expired

opened_at string (date-time) required
expires_at string (date-time) required

24 hours after it was opened. A session nobody closes marks nothing.

closed_at string or null (date-time) required

When it was completed, or when it was written off as expired.

summary object required

What the session has done. Present from the first poll, at zero: the counts mean the same thing before and after it closes.

FieldTypeNotes
items_seen integer required

How many items the batches of this session carried.

items_removed integer required

How many it marked removed when it closed.

items_restored integer required

And how many it brought back.

IngestRequest

FieldTypeNotes
sync_id string (uuid)

The session this batch belongs to, from POST /api/v1/syncs. Omit it and the push behaves exactly as it always has, marking nothing. A sync_id that is not this store's open session refuses the whole batch with unknown_sync and stores nothing.

items array required

At most 500. An empty array is refused.

IngestItem

Unknown properties are ignored rather than refused. Nothing naming a regulation is read from an item — the catalogue is one catalogue, and which rules it is measured against is chosen at scan time.

FieldTypeNotes
external_id string required

Your own identifier, trimmed. A leading space is invisible in a spreadsheet and would file the same product twice.

locale object required
fields object required

The product's content, keyed by field name. Whatever your platform calls its columns: name, description_short, description_long, and a nested attributes object are the usual shape. This is the only part of the item that is scanned, and the only part that decides whether a snapshot is new.

canonical_url string or null (uri)

The product's public URL. Recommended, not required — nothing about scanning needs it, and a report links its findings to it.

It must be on the domain this store proved it owns, or a subdomain of it. www.sklep.pl under a verified sklep.pl is fine; sklep.pl.somewhere-else.tld is a different registrable domain and is refused. A catalogue of rows pointing at somebody else's shop is the beginning of a tool for auditing other people's shops, which this deliberately is not.

Omitted means unchanged on an update.

published boolean or null

Whether the product is live on your site. Defaults to false on a new item; omitted means unchanged on an update.

An unpublished item is stored and fully scannable. Checking copy before it goes live is the one thing an integration can do that reading a public shop cannot.

IngestResponse

FieldTypeNotes
items array required

One row per item you sent, in the order you sent them.

summary object required

IngestItemResult

FieldTypeNotes
index integer required

The item's position in the batch you sent. This is what ties a row back to a line in your export — external_id is null exactly when the failure was that it could not be read, which is when you most need to know which line to look at.

external_id string or null required
status string required

unchanged is not a lesser updated: it is the right answer for almost every row of a nightly full-catalogue push, and nothing is captured, queued or billed for one.

One of: created, updated, unchanged, rejected

error object

IngestItemError

Present exactly when status is rejected.

FieldTypeNotes
code object required
detail string required

What was wrong. It quotes at most 64 characters of what you sent — an error body is the least guarded thing this API emits, and a rejection echoing a whole product description would put a merchant's copy in somebody else's log aggregator.

IngestSummary

Redundant with the rows, and worth having: an integrator watching a nightly push wants one line in a log, and computing it in every client is how four clients compute it four slightly different ways.

FieldTypeNotes
received integer required
created integer required
updated integer required
unchanged integer required
rejected integer required

CatalogItem

FieldTypeNotes
external_id string required
canonical_url string or null required
locale object required
published boolean required
removed_at string or null (date-time) required

When this product left the catalogue, or null while it is in it. A removed product stays readable here rather than answering 404, and keeps its claims, their evidence and their history; it is out of the scan scope and does not count toward your plan's catalogue ceiling. Push it again and it comes back.

fields object required
snapshot object required

Metadata about the content we hold, not the content itself — the content is in fields. What a client needs from here is whether the revision it sent is the revision we have.

created_at string (date-time) required
updated_at string (date-time) required

CheckRequest

Copy that is not in your catalogue, and which module to read it against.

There is no store and no item identifier. The shop comes from the API key — nothing in a request may name a different one — and there is no product yet, which is the reason to call this at all.

FieldTypeNotes
locale object required
regulations array required

At least one. Ask GET /regulations which codes you may use; a code you have no entitlement for is a 403 and a code that does not exist is a 422.

fields object required

The copy, keyed the way you key a catalogue item's fieldsname, description_long, attributes.material. Nesting is read one level down, exactly as it is on ingest.

Values that are not natural language are skipped rather than refused: a price, an EAN, a hex colour. What comes back on a finding is the key you sent.

One ceiling, and it is real: at most x-substantly-max-characters characters of copy across every field in one call, because the whole operation has three seconds. Over it the answer is a 422 rather than a slow unavailable — "split this" is actionable and "we were too slow" is not.

CheckStatus

How much of the analysis ran. This is the field to branch on, not the HTTP status: every answer from POST /checks is a 200, because this endpoint must never be the reason a product cannot go live.

Treat this enum as open in the sense that a client meeting a value it does not know should treat it as unavailable — degraded, and never a reason to block.

One of: complete, partial, unavailable

CheckResponse

FieldTypeNotes
status object required
locale object required
regulations array required

The modules asked for, in the order asked.

ruleset_versions object required

Regulation code to the lexicon version that actually ran. Empty when nothing ran.

Record it beside anything you keep. A finding is only reproducible against the version that produced it, and it is what tells a claim somebody fixed apart from a rule that changed.

findings array required

Ordered by regulation, then by field, then by position in the text.

An empty list only means something when status is complete. On unavailable it is the absence of an answer, not a clean bill of health.

warning object required

Present exactly when status is not complete, and null otherwise.

Not a problem document, deliberately: a degraded check is a success with less in it, and giving it the shape of an error is how a generic error handler ends up blocking a publish.

CheckWarning

FieldTypeNotes
code string required

analysis_unavailable — nothing ran. Publish anyway.

analysis_partial — the lexicon ran and the classifier did not, so the findings are real but wording that implies a benefit without naming one was not looked for.

Treat this enum as open; an unknown value means the check is degraded in a way you do not handle yet, and a degraded check is still never a reason to block.

One of: analysis_unavailable, analysis_partial

detail string required

One sentence for a person. Prose — do not parse it.

CheckFinding

One risk in one field. It has no id and is not stored anywhere: a check runs on copy that may never exist, so there is nothing for a finding to hang off. Persisted findings arrive with a scan.

FieldTypeNotes
regulation object required
field string required

The key from your fields, unchanged.

span array required

Start and end offsets into that field's readable text, counted in Unicode code points and not in bytes. Markup is stripped before the copy is read, so the offsets index into what a customer sees.

matched_text string required

The words the span covers, for a client that does not want to slice.

rule_id string or null required

Null together with legal_reference when the classifier found a claim no rule in this lexicon covers. We do not invent a citation.

legal_reference string or null required

The provision this rule implements. Null exactly when rule_id is.

claim_type string required
risk_level object required
confidence number or null required

The classifier's score, or null when layer is lexical — nobody scored it, which is a different statement from a low score. Show that rather than filling the gap with a number.

rationale string or null required

Why the classifier said so. Null on a lexical finding.

suggested_fix string or null required

Advisory, always. Substantly never modifies your content; a person approves a change to copy.

layer string required

lexical — the lexicon matched these exact words. llm — the classifier read them in context and scored them.

One of: lexical, llm

RegulationCode

A regulatory module. Never a path segment — it is a body field or a query filter, so that one catalogue is not uploaded once per module.

Ask GET /regulations which of these your organization may use. Treat this enum as open.

One of: ECGT, GPSR, COSM_655, OMNIBUS

RiskLevel

How exposed a finding leaves the merchant. Substantly reports risk with a confidence; it does not say a product is non-compliant and it issues no legal opinions.

One of: low, medium, high

ClaimStatus

How far a claim has got through its evidence library. The vocabulary is the directive's own — 2024/825 asks whether a claim is substantiated, meaning whether documentation exists — and not a verdict on the shop.

substantiated means "documentation exists and a human accepted it". It does not mean the wording is lawful, and nothing in this API says that.

Treat this enum as open.

One of: open, substantiated, unsubstantiated, retired

ClaimEvidence

One document filed against a claim, as a reference rather than as bytes.

There is no URL here on purpose. The links the console mints are scoped to a browser session and an organization, and handing an integrator one that works outside both is a different security decision from the one this endpoint makes. checksum is what lets you prove the file you hold is the file we hold without either of us moving it.

FieldTypeNotes
id string (uuid) required
filename string or null required

As the merchant's browser sent it. Null for evidence with no file — a certificate whose paperwork lives with the issuer.

size_bytes integer or null required
checksum string or null required

SHA-256 of the stored bytes, lower-case hex.

attached_at string (date-time) required

Claim

The durable record. It survives edits to the copy the analysis found it in, which is what lets evidence accumulate against it — a detection is one hit in one snapshot, and rewording the sentence produces a new detection against this same claim.

FieldTypeNotes
id string (uuid) required
store_id string (uuid) required

The shop, which is always the one your key was issued to.

external_id string required

Your own identifier for the product, exactly as you sent it to the catalogue.

claim_type string required

What kind of claim the analysis found, as the lexicon names it. Not an enum: the lexicon is versioned YAML that grows with the law, so a claim type arrives without a release of this API.

regulation object required
status object required
risk object required

The highest risk among the findings filed under this claim. Null until one has been. It never falls on its own: a rewording that drops the worst phrase leaves the claim where it was until somebody reviews it.

assignee object or null required

The person who owns getting this documented, or null when nobody has picked it up.

FieldTypeNotes
id string (uuid) required
email string (email) required
due_at string or null (date) required

The merchant's own working date, YYYY-MM-DD. Nothing in 2024/825 sets it and nothing here treats a missed one as a finding.

reviewed boolean required

Whether a person has looked at this claim. Its own fact, not a reading of status — a claim can be triaged and still open.

reviewed_at string or null (date-time) required
detection_count integer required

How many findings are filed under this claim, across snapshots and ruleset versions.

evidence_count integer required
evidence array required
version integer required

What the row is at. Increments on every change to the claim.

etag string required

version in the form If-Match wants. Send it back verbatim on your next patch of this claim rather than assembling it yourself.

created_at string (date-time) required
updated_at string (date-time) required

The instant the claim last changed. updated_since filters on this, and pages are ordered by it.

ClaimList

FieldTypeNotes
claims array required
next_cursor string or null required

Pass as cursor for the next page. Null when this is the last one — which is the only condition to stop on, because a full page is not a promise that another exists.

ClaimPatch

The four fields this endpoint sets. Send only the ones you are changing; a field you do not name is left alone.

Anything else is refused rather than ignored — claim_type, regulation and risk included. They are facts the analysis produced, and a request that quietly dropped them would look to you exactly like one that applied them.

FieldTypeNotes
status object
note string

Why. Required when leaving substantiated for unsubstantiated, optional on any other move, and refused without a status — a note about nothing is not a claim's history.

assignee string or null (uuid)

A member of your organization, by id, or null to take the claim off everybody. Somebody outside your organization is refused: there is no reading of "assign" that spans two customers.

due_at string or null (date)

The merchant's own working date, YYYY-MM-DD, or null to clear it.

reviewed boolean

true records that a person has looked at the claim. There is no false: un-reviewing is not a thing the register does, and a claim that comes back is a decision about resolution rather than a button.

One of: 1

CreateScanRequest

FieldTypeNotes
regulations array required
scope object

Omit to scan the whole catalogue. An object rather than a bare array so that later scope types can be added beside external_ids; a key this version does not recognise is refused rather than ignored.

FieldTypeNotes
external_ids array

The identifiers you sent to POST /catalog/items, matched exactly. One that is not in your catalogue is skipped in silence.

ScanAccepted

FieldTypeNotes
id string (uuid) required
status string required

One of: queued

ScanStatus

Where a scan stands. paused means the organization's daily analysis budget ran out: nothing is lost and the scan resumes when the budget resets, which is why it is not a terminal state. A scan whose worker died goes back to queued rather than to failed — a bulk scan never reports a catalogue as clean because something stopped reading it.

Treat this enum as open, as with every other enum in this contract.

One of: queued, running, paused, completed, failed, canceled

Scan

FieldTypeNotes
id string (uuid) required
status object required
progress object
FieldTypeNotes
items_total integer

What the scan was sized at when it was queued. Products added while it runs are not in it.

items_scanned integer
items_failed integer

Products the analysis could not read. Reported separately rather than folded into items_scanned, because a scan that skipped four products has not looked at them.

regulations array required
FieldTypeNotes
code object required
ruleset_version string required

The lexicon version this scan ran at, recorded with the scan rather than looked up afterwards. Without it a re-run is not reproducible.

counts_by_risk object required
FieldTypeNotes
low integer
medium integer
high integer
report_id string or null (uuid)
created_at string (date-time) required
completed_at string or null (date-time)

RegulationList

FieldTypeNotes
regulations array required
FieldTypeNotes
code object required
name string required
jurisdiction string required
applies_from string or null (date) required
availability string required

Whether the module is built. planned codes are listed rather than omitted, so you can see what is coming.

One of: available, planned

entitled boolean required

Whether this organization may scan against it today.

ruleset_version string or null required
locales array required

The languages a scan may actually run in today: those whose measured recall and precision cleared the publication gate, plus any being served under a recorded emergency exception.

Narrower than the languages the ruleset carries citations for. A language that has not been validated is a 422 on POST /api/v1/scans, and listing it here would send you straight into the refusal this endpoint exists to prevent.

locale_readiness array required

Every language the ruleset carries citations for, validated or not, with the measurement behind each verdict.

This is the list that distinguishes "we do not cover German" from "German is measured and not yet validated", and only one of those is worth planning around. locales above is the subset you may scan today.

override boolean required

Whether any language of this module is being served under a recorded exception — released before its measurement met the thresholds, for a stated reason and for a bounded period.

Findings produced this way are labelled as such in the report footer. The flag is repeated here, out of the per-language list, so a client can check it without walking entries.

LocaleReadiness

One language of one module: whether it is validated, and on what evidence.

The measurement is reported rather than only the verdict. A decision about whether to trust a German scan is better served by 0.87 against a target of 0.90 than by a single word — the same argument that puts a confidence on every detection.

FieldTypeNotes
locale object required
stage string required

production — measured against a held-out corpus and above both thresholds. candidate — not validated for this language; scans in it are refused. override — below threshold and released anyway under a recorded exception.

One of: production, candidate, override

recall number or null required

As measured. Null for a language nothing has scored, which is not the same as one scored at zero.

precision number or null required
reason string required

One sentence saying how this language reached this stage — too few labelled examples, a measurement below target, a corpus that has moved since. Written for a person, not parsed.

override object or null required

Present only when this language's stage is an override.

FieldTypeNotes
reason string required
granted_by string required
granted_at string (date) required
expires_at string (date) required

The exception lapses on this date and the language returns to candidate on its own.

Report

FieldTypeNotes
id string (uuid) required
scan_id string (uuid) required
store object required

Taken from the proved domain ownership, never from a field anybody typed. A document that asserts whose shop this is has to have checked.

FieldTypeNotes
name string required
domain string required
verification object required

How and when that ownership was proved. It is on the report because the report is evidence: a reader who has to take the shop's identity on trust has nothing to check.

FieldTypeNotes
method string required

One of: dns_txt, well_known_file

proved_at string (date-time) required
status string required

Rendering is asynchronous. Poll this rather than the download URL. failed is terminal — the findings are untouched and the report can be asked for again.

revoked is also terminal, and means something different: the document rendered correctly and the purchase that paid for it was reversed, so it is no longer served. The scan and its findings are untouched. A copy already downloaded is not recalled — it cannot be — but download_url is null from that point on.

One of: queued, rendering, ready, failed, revoked

generated_at string (date-time) required
ruleset_versions array required

Every ruleset version behind a finding in this report, sorted. More than one when a scan spanned a ruleset release. The same list is printed in the footer of every page of the PDF, because a finding nobody can reproduce is a finding nobody can act on.

overridden_ruleset_versions array required

Which of ruleset_versions had not been validated for the language they ran in, and were released under a recorded exception anyway. Empty in every ordinary report, which is the point: a client that files these documents can tell one apart without reading the PDF.

It is the state at the moment the scan ran, not the state today — an exception expires and a passing manifest replaces it, so asking later would answer a question about later.

analysis_versions array

Which build of the analysis produced the findings. Beside ruleset_versions rather than instead of it: a ruleset version says which rules ran, this says which code applied them, and a finding can move between two releases without a single rule changing.

Empty for a scan run before this was recorded — an absent version is stated as absent rather than filled in with the version running now, which is not the one that answered.

download_url string or null (uri)

A signed, short-lived link for a person — it opens in the browser session of a signed-in member of this organization. Null until status is ready. An integration should request application/pdf from this endpoint instead of following it.

download_url_expires_at string or null (date-time)

When the link above stops working. Fetch the report again for a fresh one; the report itself is kept.

sections array required

One entry per regulation the scan's results mention — always, even when there is only one, which today there always is. The shape does not change when the next regulatory module lands.

FieldTypeNotes
regulation object required
items integer required
detections integer required
ruleset_versions array required

StripeWebhookAck

What the inbound Stripe endpoint answers an authentic delivery with. All three are a 200; the field says which the delivery came to. processed applied a change, duplicate had seen the event before, ignored was authentic and not ours to act on — an event type not handled, or one naming a checkout or subscription no organization here started.

FieldTypeNotes
status string required

One of: processed, duplicate, ignored

WebhookEventType

Every event Substantly will send, including the two nothing raises yet. x-substantly-raised on the webhook says which is which.

Treat this enum as open in the same way as ErrorCode: a future release may add a member. A receiver that ignores a type it does not handle keeps working; one that throws on an unknown type does not.

One of: scan.completed, scan.failed, certificate.expiring, ruleset.updated, claim.status_changed

WebhookEnvelope

The five fields every delivery carries, whatever its type. Written down once and fixed: a receiver that reads id, type and store_id without looking inside data will keep working for every event type added later.

created_at is when the event happened, never when it was sent. A delivery retried the next morning still describes the night before, and ordering your own timeline by this field is the point of it.

FieldTypeNotes
id string required

Stable across every delivery attempt of the same event, and the key your handler should be idempotent on. It sorts into the order the events happened, so "what did I miss" is answerable.

type object required
created_at string (date-time) required

RFC 3339, UTC, seconds precision.

store_id string (uuid) required

The shop the event is about. On the envelope rather than inside each payload because every event this product raises is about one shop.

data object required

The payload for this type. Its fields are fixed per type and all of them are always present.

WebhookScanCompletedData

FieldTypeNotes
scan_id string (uuid) required

Fetch the findings with GET /api/v1/detections?scan_id=….

detections integer required

How many findings the scan produced. A count, never the findings themselves.

regulations array required

The regulation codes this scan was run for.

WebhookScanFailedData

FieldTypeNotes
scan_id string (uuid) required
regulations array required
items_scanned integer required

Products the scan got through before it gave up.

items_failed integer required

WebhookCertificateExpiringData

FieldTypeNotes
claim_id string (uuid) required

The claim this certificate was filed against.

certificate_id string (uuid) required
expires_at string (date-time) required

Midnight UTC of the last day the certificate is valid. It is valid through that day.

days_remaining integer required

Which of the four windows this is, not the exact remainder. 0 is the day it stops being valid.

One of: 90, 30, 7, 0

WebhookRulesetUpdatedData

FieldTypeNotes
regulation string required
ruleset_version string required
previous_ruleset_version string required

What the counts are measured against. An empty string when this is the first published version of the module — never absent.

items_scanned integer required

How many catalogue items the rescan covered.

new_findings integer required

Detections this version produced that no earlier version of this regulation's lexicon had found on the same product, rule and field.

resolved_findings integer required

Findings an earlier version had on those products that this one did not produce. A count, not a list: what is being counted is precisely what is no longer there.

WebhookClaimStatusChangedData

FieldTypeNotes
claim_id string (uuid) required
previous_status string required
status string required

WebhookScanCompleted

FieldTypeNotes
type object
data object

WebhookScanFailed

FieldTypeNotes
type object
data object

WebhookCertificateExpiring

FieldTypeNotes
type object
data object

WebhookRulesetUpdated

FieldTypeNotes
type object
data object

WebhookClaimStatusChanged

FieldTypeNotes
type object
data object