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
https://api.substantly.eu— Production. `sbl_live_` and `sbl_test_` keys both authenticate here.
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
| Name | In | Required | Description |
|---|---|---|---|
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 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
| Field | Type | Notes | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
sync_id |
string (uuid) |
The session this batch belongs to, from |
|||||||||||||||||||||||||
items |
array | required |
At most 500. An empty array is refused.
|
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
| Status | Meaning |
|---|---|
200 |
Every item was stored. Nothing was rejected. |
207 |
At least one item was rejected and the rest were stored. Read
|
400 |
The request body is not valid JSON. |
401 |
The key is missing, malformed, revoked or expired. |
403 |
The store is not verified ( Either way the batch was not processed: nothing was written, not
even the items that would have fitted, and an |
409 |
One
|
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
|
429 |
This key's ceiling for the current minute is spent. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
200 shape and examples
| Header | Description |
|---|---|
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 |
application/json
| Field | Type | Notes | |||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
items |
array | required |
One row per item you sent, in the order you sent them.
|
||||||||||||||||||||||||||||||||
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.
|
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
| Header | Description |
|---|---|
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 |
application/json
| Field | Type | Notes | |||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
items |
array | required |
One row per item you sent, in the order you sent them.
|
||||||||||||||||||||||||||||||||
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.
|
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
| Field | Type | Notes | |
|---|---|---|---|
limit |
integer |
The ceiling that was exceeded. Present on |
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
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Name | In | Required | Description |
|---|---|---|---|
externalId |
path | yes | The merchant's own identifier for the product, exactly as it was sent. |
Responses
| Status | Meaning |
|---|---|
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.
Fix it in the console at |
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. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
200 shape and examples
| Header | Description |
|---|---|
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
| Field | Type | Notes | |
|---|---|---|---|
external_id |
string | required | |
canonical_url |
string or null | required | |
locale |
string | required |
An official EU language, optionally with a two-letter region subtag —
The allowlist is all 24 official languages of the Union, not the one
language a lexicon exists for today. Send your German copy as |
published |
boolean | required | |
removed_at |
string or null (date-time) | required |
When this product left the catalogue, or |
fields |
object | required | |
snapshot |
object | required |
Metadata about the content we hold, not the content itself — the
content is in |
created_at |
string (date-time) | required | |
updated_at |
string (date-time) | required |
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
| Field | Type | Notes | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
store |
object | required |
|
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Name | In | Required | Description |
|---|---|---|---|
externalId |
path | yes | The merchant's own identifier for the product, exactly as it was sent. |
Responses
| Status | Meaning |
|---|---|
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 A path that has not yet been reserved in the router answers |
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Status | Meaning |
|---|---|
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 A path that has not yet been reserved in the router answers |
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Status | Meaning |
|---|---|
202 |
The session is open. Poll the |
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 ( |
429 |
This key's ceiling for the current minute is spent. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
202 shape and examples
| Header | Description |
|---|---|
Location |
The polling URL for this session. |
application/json
| Field | Type | Notes | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id |
string (uuid) | required | |||||||||||||||||
status |
string | required |
One of: |
||||||||||||||||
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.
|
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
| Field | Type | Notes | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
store |
object | required |
|
409 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
429 shape and examples
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes |
Responses
| Status | Meaning |
|---|---|
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. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
200 shape and examples
application/json
| Field | Type | Notes | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id |
string (uuid) | required | |||||||||||||||||
status |
string | required |
One of: |
||||||||||||||||
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.
|
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
429 shape and examples
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes |
Request body
application/json, required
| Field | Type | Notes | |
|---|---|---|---|
status |
string | required |
The only transition this resource has. One of: |
close
{
"status": "complete"
}
Responses
| Status | Meaning |
|---|---|
200 |
The session is closed. |
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 ( |
422 |
The body is not |
429 |
This key's ceiling for the current minute is spent. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
200 shape and examples
application/json
| Field | Type | Notes | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id |
string (uuid) | required | |||||||||||||||||
status |
string | required |
One of: |
||||||||||||||||
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.
|
400 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
| Field | Type | Notes | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
store |
object | required |
|
404 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
409 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
422 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
429 shape and examples
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
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
|
wholeCatalogue
{
"regulations": [
"ECGT"
]
}
namedProducts
{
"regulations": [
"ECGT"
],
"scope": {
"external_ids": [
"SKU-123",
"SKU-124"
]
}
}
Responses
| Status | Meaning |
|---|---|
202 |
The scan was queued. Poll the |
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
|
429 |
This key's ceiling for the current minute is spent. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
202 shape and examples
| Header | Description |
|---|---|
Location |
The polling URL for this scan. |
application/json
| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
status |
string | required |
One of: |
400 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
422 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
429 shape and examples
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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 status — report_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
| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes |
Responses
| Status | Meaning |
|---|---|
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. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
200 shape and examples
application/json
| Field | Type | Notes | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id |
string (uuid) | required | |||||||||||||||||
status |
string | required |
Where a scan stands. Treat this enum as open, as with every other enum in this contract. One of: |
||||||||||||||||
progress |
object |
|
|||||||||||||||||
regulations |
array | required |
|
||||||||||||||||
counts_by_risk |
object | required |
|
||||||||||||||||
report_id |
string or null (uuid) | ||||||||||||||||||
created_at |
string (date-time) | required | |||||||||||||||||
completed_at |
string or null (date-time) |
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
429 shape and examples
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
locale |
string | required |
An official EU language, optionally with a two-letter region subtag —
The allowlist is all 24 official languages of the Union, not the one
language a lexicon exists for today. Send your German copy as |
regulations |
array | required |
At least one. Ask |
fields |
object | required |
The copy, keyed the way you key a catalogue item's 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 |
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
| Status | Meaning |
|---|---|
200 |
The check ran, or degraded to a warning. Both are this status —
read |
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. |
422 |
The body cannot be acted on: a missing or unsupported |
429 |
This key's ceiling for the current minute is spent. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
200 shape and examples
| Header | Description |
|---|---|
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
| Field | Type | Notes | |||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
status |
string | required |
How much of the analysis ran. This is the field to branch on, not
the HTTP status: every answer from Treat this enum as open in the sense that a client meeting a value it
does not know should treat it as One of: |
||||||||||||||||||||||||||||||||||||||||||||||||||||
locale |
string | required |
An official EU language, optionally with a two-letter region subtag —
The allowlist is all 24 official languages of the Union, not the one
language a lexicon exists for today. Send your German copy as |
||||||||||||||||||||||||||||||||||||||||||||||||||||
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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
warning |
object | required |
Present exactly when 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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
422 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
429 shape and examples
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes |
Responses
| Status | Meaning |
|---|---|
401 |
The key is missing, malformed, revoked or expired. |
429 |
This key's ceiling for the current minute is spent. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
501 |
A reserved path that is not served yet. It is a A path that has not yet been reserved in the router answers |
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Status | Meaning |
|---|---|
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. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
200 shape and examples
application/json
| Field | Type | Notes | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
regulations |
array | required |
|
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes |
Responses
| Status | Meaning |
|---|---|
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
| Field | Type | Notes | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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.
|
||||||||||||||||||||
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.
|
||||||||||||||||||||
status |
string | required |
Rendering is asynchronous. Poll this rather than the download URL.
One of: |
||||||||||||||||||||
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 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
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
|
|||||||||||||||||||||
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.
|
application/pdf
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
409 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Name | In | Required | Description |
|---|---|---|---|
regulation |
query | no | Restrict to findings from one regulatory module. |
risk |
query | no |
Responses
| Status | Meaning |
|---|---|
200 |
The findings. |
401 |
The key is missing, malformed, revoked or expired. |
501 |
A reserved path that is not served yet. It is a A path that has not yet been reserved in the router answers |
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Name | In | Required | Description |
|---|---|---|---|
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 |
status |
query | no | One or more statuses, comma-separated: |
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 |
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 |
cursor |
query | no | The |
limit |
query | no | Claims per page. |
Responses
| Status | Meaning |
|---|---|
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 ( |
422 |
A filter value this endpoint cannot read: a |
429 |
This key's ceiling for the current minute is spent. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
200 shape and examples
application/json
| Field | Type | Notes | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
claims |
array | required |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
next_cursor |
string or null | required |
Pass as |
401 shape and examples
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
422 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
429 shape and examples
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | |
If-Match |
header | yes | The version of the claim you are patching, exactly as its 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. A value that is not the claim's current version is |
Request body
application/json, required
| Field | Type | Notes | |
|---|---|---|---|
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.
Treat this enum as open. One of: |
|
note |
string |
Why. Required when leaving |
|
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, |
|
reviewed |
boolean |
One of: |
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
| Status | Meaning |
|---|---|
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 ( |
404 |
No claim with that identifier belongs to the shop this key was issued to ( |
412 |
The |
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 ( |
428 |
No usable |
429 |
This key's ceiling for the current minute is spent. The limit is per key, not per organization: a second key issued for a
nightly batch job gets its own budget, and a |
200 shape and examples
| Header | Description |
|---|---|
ETag |
The claim's new version. Send it as |
application/json
| Field | Type | Notes | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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 One of: |
||||||||||||||||||||||||
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.
Treat this enum as open. One of: |
||||||||||||||||||||||||
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.
|
||||||||||||||||||||||||
due_at |
string or null (date) | required |
The merchant's own working date, |
||||||||||||||||||||||||
reviewed |
boolean | required |
Whether a person has looked at this claim. Its own fact, not a reading of |
||||||||||||||||||||||||
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 |
|
||||||||||||||||||||||||
created_at |
string (date-time) | required | |||||||||||||||||||||||||
updated_at |
string (date-time) | required |
The instant the claim last changed. |
400 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Header | Description |
|---|---|
WWW-Authenticate |
Always |
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
404 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
412 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
422 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
428 shape and examples
application/problem+json
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
detail |
string | required |
What went wrong, in a sentence. Prose — do not parse it. |
429 shape and examples
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
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 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: |
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
- Read the header and split it on
,then=. You wanttandv1. - Compute
HMAC-SHA256(key = your endpoint secret, message = t + "." + raw request body)and compare it tov1in constant time. - Reject the delivery if
tis more thantolerance-secondsaway 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.
| Field | Type | Notes | |
|---|---|---|---|
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. Treat this enum as open in the same way as One of: |
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 |
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.
| Field | Type | Notes | |
|---|---|---|---|
scan_id |
string (uuid) | required |
Fetch the findings with |
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.
| Field | Type | Notes | |
|---|---|---|---|
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.
| Field | Type | Notes | |
|---|---|---|---|
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. One of: |
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.
| Field | Type | Notes | |
|---|---|---|---|
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.
| Field | Type | Notes | |
|---|---|---|---|
claim_id |
string (uuid) | required | |
previous_status |
string | required | |
status |
string | required |
Error codes
Every error is application/problem+json (RFC 9457).
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.
Request errors
The code of a problem document.
| Code | Status | When | What 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 |
Send |
batch_too_large |
422 | More items than one call may carry. |
Split the batch. The ceiling is in the |
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 |
sync_already_open |
409 |
|
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 |
Open a session and use the id it returns. Nothing in the refused batch was stored, and an |
invalid_api_key |
401 | The key is missing, malformed, revoked or expired. |
Check the |
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. |
Finish verification in the console at |
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 |
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 |
claim_not_found |
404 | No claim with that identifier belongs to the shop this key was issued to. |
Check the identifier against |
evidence_required |
422 |
|
File the document first, then send the status. |
note_required |
422 | Leaving |
Send |
illegal_transition |
422 | The workflow has no move from the status the claim is in to the one you asked for. A |
Read |
claim_changed |
412 | The |
Read the claim again, decide whether your change still applies to what it now says, and send it with the |
if_match_required |
428 | A |
Send |
idempotency_key_conflict |
409 | This |
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 |
unknown_regulation |
422 | A |
Call |
not_implemented |
501 | A path that is reserved in this contract and not served yet. Every operation marked |
Read |
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 ( |
Check the path against this document. A reserved path answers |
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 |
method_not_allowed |
405 | The path is served, but not for the method you used. |
Use one of the methods in the |
rate_limit_exceeded |
429 | This key's ceiling for the current minute is spent. |
Wait |
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.
| Code | Status | When | What to do |
|---|---|---|---|
invalid_item |
207 | The item is not a JSON object, or |
Fix that row. The rest of the batch was stored. |
invalid_external_id |
207 |
|
Read |
unsupported_locale |
207 |
|
Send the language, optionally with a region: |
invalid_fields |
207 |
|
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 |
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.
| Field | Type | Notes | |
|---|---|---|---|
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
| Field | Type | Notes | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
store |
object | required |
|
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.
| Field | Type | Notes | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
feature |
string |
What the plan does not carry. Absent when a limit was reached rather than a feature refused. One of: |
|||||||||||||||||
limit |
object |
The ceiling that was reached. Absent when a feature was refused.
|
|||||||||||||||||
upgrade |
object | required |
What to buy so the same request succeeds. This is the documented
upgrade path:
|
BatchProblem
| Field | Type | Notes | |
|---|---|---|---|
limit |
integer |
The ceiling that was exceeded. Present on |
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
| Field | Type | Notes | |
|---|---|---|---|
status |
string | required |
The only transition this resource has. One of: |
Sync
| Field | Type | Notes | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id |
string (uuid) | required | |||||||||||||||||
status |
string | required |
One of: |
||||||||||||||||
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.
|
IngestRequest
| Field | Type | Notes | |
|---|---|---|---|
sync_id |
string (uuid) |
The session this batch belongs to, from |
|
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.
| Field | Type | Notes | |
|---|---|---|---|
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: |
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. Omitted means unchanged on an update. |
|
published |
boolean or null |
Whether the product is live on your site. Defaults to 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
| Field | Type | Notes | |
|---|---|---|---|
items |
array | required |
One row per item you sent, in the order you sent them. |
summary |
object | required |
IngestItemResult
| Field | Type | Notes | |
|---|---|---|---|
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 |
string or null | required | |
status |
string | required |
One of: |
error |
object |
IngestItemError
Present exactly when status is rejected.
| Field | Type | Notes | |
|---|---|---|---|
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.
| Field | Type | Notes | |
|---|---|---|---|
received |
integer | required | |
created |
integer | required | |
updated |
integer | required | |
unchanged |
integer | required | |
rejected |
integer | required |
CatalogItem
| Field | Type | Notes | |
|---|---|---|---|
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 |
fields |
object | required | |
snapshot |
object | required |
Metadata about the content we hold, not the content itself — the
content is in |
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.
| Field | Type | Notes | |
|---|---|---|---|
locale |
object | required | |
regulations |
array | required |
At least one. Ask |
fields |
object | required |
The copy, keyed the way you key a catalogue item's 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 |
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
| Field | Type | Notes | |
|---|---|---|---|
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 |
warning |
object | required |
Present exactly when 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
| Field | Type | Notes | |
|---|---|---|---|
code |
string | required |
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: |
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.
| Field | Type | Notes | |
|---|---|---|---|
regulation |
object | required | |
field |
string | required |
The key from your |
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 |
string or null | required |
The provision this rule implements. Null exactly when |
claim_type |
string | required | |
risk_level |
object | required | |
confidence |
number or null | required |
The classifier's score, or null when |
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 |
One of: |
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.
| Field | Type | Notes | |
|---|---|---|---|
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.
| Field | Type | Notes | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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.
|
||||||||||||
due_at |
string or null (date) | required |
The merchant's own working date, |
||||||||||||
reviewed |
boolean | required |
Whether a person has looked at this claim. Its own fact, not a reading of |
||||||||||||
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 |
|
||||||||||||
created_at |
string (date-time) | required | |||||||||||||
updated_at |
string (date-time) | required |
The instant the claim last changed. |
ClaimList
| Field | Type | Notes | |
|---|---|---|---|
claims |
array | required | |
next_cursor |
string or null | required |
Pass as |
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.
| Field | Type | Notes | |
|---|---|---|---|
status |
object | ||
note |
string |
Why. Required when leaving |
|
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, |
|
reviewed |
boolean |
One of: |
CreateScanRequest
| Field | Type | Notes | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
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
|
ScanAccepted
| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
status |
string | required |
One of: |
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
| Field | Type | Notes | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id |
string (uuid) | required | |||||||||||||||||
status |
object | required | |||||||||||||||||
progress |
object |
|
|||||||||||||||||
regulations |
array | required |
|
||||||||||||||||
counts_by_risk |
object | required |
|
||||||||||||||||
report_id |
string or null (uuid) | ||||||||||||||||||
created_at |
string (date-time) | required | |||||||||||||||||
completed_at |
string or null (date-time) |
RegulationList
| Field | Type | Notes | |||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
regulations |
array | required |
|
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.
| Field | Type | Notes | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
locale |
object | required | |||||||||||||||||||||
stage |
string | required |
One of: |
||||||||||||||||||||
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.
|
Report
| Field | Type | Notes | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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.
|
||||||||||||||||||||
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.
|
||||||||||||||||||||
status |
string | required |
Rendering is asynchronous. Poll this rather than the download URL.
One of: |
||||||||||||||||||||
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 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
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
|
|||||||||||||||||||||
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.
|
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.
| Field | Type | Notes | |
|---|---|---|---|
status |
string | required |
One of: |
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.
| Field | Type | Notes | |
|---|---|---|---|
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 |
WebhookScanCompletedData
| Field | Type | Notes | |
|---|---|---|---|
scan_id |
string (uuid) | required |
Fetch the findings with |
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
| Field | Type | Notes | |
|---|---|---|---|
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
| Field | Type | Notes | |
|---|---|---|---|
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. One of: |
WebhookRulesetUpdatedData
| Field | Type | Notes | |
|---|---|---|---|
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
| Field | Type | Notes | |
|---|---|---|---|
claim_id |
string (uuid) | required | |
previous_status |
string | required | |
status |
string | required |
WebhookScanCompleted
| Field | Type | Notes | |
|---|---|---|---|
type |
object | ||
data |
object |
WebhookScanFailed
| Field | Type | Notes | |
|---|---|---|---|
type |
object | ||
data |
object |
WebhookCertificateExpiring
| Field | Type | Notes | |
|---|---|---|---|
type |
object | ||
data |
object |
WebhookRulesetUpdated
| Field | Type | Notes | |
|---|---|---|---|
type |
object | ||
data |
object |
WebhookClaimStatusChanged
| Field | Type | Notes | |
|---|---|---|---|
type |
object | ||
data |
object |