Sustained Product Footprint API — integration guide
The companion to the API reference. The reference tells you what every field is; this tells you how the API thinks, which is the part that decides whether an integration goes smoothly.
Supersedes the Sustained High-res / Low-res Product Footprint API PDFs. Where the two disagree, this is correct — see §5 for one case worth checking in existing code.
1. The model
| Entity | What it is |
|---|---|
| Integration | Your connection to Sustained. Owns customers. One API key, one integration. |
| Customer | A scope of data for a single end customer. Owns workspaces. |
| Workspace | A namespace isolating products and reports — typically a brand or an environment. |
| Product | One SKU model. |
| Report | The environmental impact result for a product: physical quantities, scores and aggregates. |
If you are a platform serving many brands, each brand is a customer; their environments are workspaces.
2. Authentication and tenancy
Send your API key on every request:
x-api-key: <your key>
The key identifies your integration. To choose which customer and workspace a request targets, add:
| Header | Effect |
|---|---|
X-Integration-Customer-Alias |
Selects the customer. Globally unique. |
X-Integration-Customer-Name |
Display name, applied only when the customer is first created. |
X-Integration-Workspace-Alias |
Selects the workspace. Unique per customer. Ingestion only. |
Aliases are yours to choose. Use your own customer identifiers — you do not need to store Sustained IDs to route data.
Unknown aliases are provisioned automatically. The first request carrying a new customer alias creates that customer; the same is true of workspaces. There is no separate onboarding call.
Omit an alias and the integration's default customer or workspace is used. That is convenient for a single-tenant integration and a trap for a multi-tenant one: a missing header does not fail, it writes to the default. If you serve multiple customers, always send the alias.
You can only reach workspaces created by your integration or explicitly onboarded to it.
3. How ingestion works
This is the part that is unlike most APIs, and the part worth reading twice.
Send your vocabulary, not ours
Every scalar is a three-part envelope:
{
"category": { "source": "Dairy alternatives", "target": "cat.dairy_alt", "tag": "exact_match" }
}
| Field | Who sets it | Meaning |
|---|---|---|
source |
You | Your value, in your own words |
target |
Sustained | The resolved Sustained entity |
tag |
Sustained | How confident the match was |
You normally send only source. Sustained's mapping layer resolves target during
ingestion, and returns the completed document when you read the product back.
The point: you do not have to adopt Sustained's taxonomy, pre-map your catalogue, or
maintain a translation table. Send "Dairy alternatives", or "DAIRY-ALT", or whatever
your PLM calls it, and it resolves.
If you already know the Sustained identifier, set target yourself and it is used as-is.
Read the tags before you trust the number
Every resolved field comes back with a tag:
| Tag | What it means |
|---|---|
exact_match |
High-confidence exact match |
best_match |
Plausible match, medium confidence |
low_quality_match |
Low confidence. Mapped and included, but worth reviewing |
default |
You sent nothing, so a Sustained default was applied |
unmatched |
No match found. Excluded from the assessment entirely |
user_input |
Taken as given, no matching applied |
unmatched is the one to watch: that ingredient contributes no impact at all, so the
footprint is understated rather than wrong-looking. A sensible integration surfaces
unmatched and low_quality_match counts to whoever owns the data.
report.firstPartyData gives you the same signal in aggregate — the percentage of the result
derived from what you sent, rather than from defaults.
Sensible defaults, not failures
Where you leave things out, Sustained fills them in rather than rejecting the product. The
result is a complete assessment with a lower firstPartyData score.
Suppliers — for ingredients, packaging and facility owners:
- No supplier at all → at least one default supplier is created. Ingredients with several countries of origin may get several.
- No supplier location → the production facility's country, or the United Kingdom.
- No supplier ID → a default supplier is created from the location.
- No supplier name → a name is generated from the location.
Facilities — production, storage, retail, consumption and final destination:
- No production facility → one is created.
- No storage facility → one is created, for final and intermediate products. Internal products get none.
- No retail facility → one is created, for final products only.
- No consumption location → one is created, for final products only.
- No final destination → one is created, for internal products only.
- Missing facility ID, name or location → defaulted from the location, exactly as for suppliers.
Note the product-type asymmetry: retail and consumption belong to final products, final destination belongs to internal products.
4. Choosing a resolution
| Low-res | High-res | |
|---|---|---|
| For | Screening a wide catalogue | Decision-grade modelling |
| Ingredients, materials, production facilities | Yes | Yes |
| Packaging | Rejected | Yes |
| Distribution paths | Rejected | Yes |
| Product groups | Rejected | Yes |
| Ingredient loss | Rejected | Yes |
| Facility emissions and waste | Rejected | Yes |
| Report | Score + categories | Categories, contributor tree, transport, consumer label |
Rejected means 400, not ignored. Sending packaging to a low-res endpoint fails the
whole request with packaging is not supported in low resolution use-case. This is
deliberate — silently dropping half a product document produces a plausible, wrong number.
The two are separate worlds. A SKU ingested at low resolution cannot be read through the
high-res API: you get 401, not 404. To change resolution, delete and re-ingest.
5. Ingestion is asynchronous
A successful POST returns 201 with a job, not a report:
{
"id": "PW1002",
"status": "queued",
"customerId": "customerid.f238625e01f845fabd1100585225af06",
"workspaceId": "workspace.33163c24ac1241fa8649e811d57b8a9f",
"createdAt": "2026-08-07T09:04:11Z",
"updatedAt": "2026-08-07T09:04:11Z"
}
Poll the matching GET until status settles:
queued → mapping → calculating → finished
↘ failed
| Status | Meaning |
|---|---|
queued |
Accepted, not started |
mapping |
Resolving your source values to Sustained entities |
calculating |
Running the impact calculation |
finished |
Done. report is populated |
failed |
Failed. error explains why |
product appears once mapping completes, so you can inspect the resolved document — and its
tags — before the report exists.
Once finished, check productState:
complete— fully resolved, report is finaldraft— ingestion raised warnings; the report is indicative
Note the spelling:
complete, notcompleted. Earlier PDF revisions of this document saidcompletedin prose while the API has always returnedcomplete. If you have a comparison against"completed", it has never matched.
Poll politely. Back off between attempts rather than tight-looping — see §7.
6. Working with SKUs
skuId.source is your identifier and the key for everything afterwards. It is unique per
customer, across all of that customer's workspaces.
POST /v1/ingest/{resolution}— first ingestion. SKU taken from the body.POST /v1/ingest/{resolution}/{skuId}— replace an existing SKU.
On the second form, skuId.source in the body must equal the path segment. A mismatch is a
400, not a silent overwrite of whichever one you meant.
Deleting a product that another product depends on — an internal or intermediate product used
as an ingredient elsewhere — returns 409 with errorCode: resource-in-use. Delete the
dependants first.
7. Errors
All errors share one shape:
{ "message": "distribution paths are not supported in low resolution use-case",
"response_code": 400 }
Some carry an errorCode for programmatic handling.
| Status | When | What to do |
|---|---|---|
400 |
Validation failure, SKU mismatch, unknown category, low-res restriction | Fix the request. Retrying will not help |
401 |
Bad API key, or reading a SKU at the wrong resolution | Check the key, or use the other resolution's endpoint |
404 |
No such SKU for this customer | Check the alias headers — you may be looking in the default customer |
409 |
Product is in use by another product | Delete the dependants first |
429 |
Throttled | Back off exponentially with jitter, then retry |
500 |
Our fault | Retry with backoff. Quote X-Sustained-Request-Sid if it persists |
message names what actually failed — read it rather than pattern-matching on the status
code alone.
Every response carries an X-Sustained-Request-Sid header. Log it. It is the fastest route
to an answer from support.
8. Quickstart
Low-res — screening
# 1. Ingest
curl -X POST https://api.sustained.com/impact/v1/ingest/lowres \
-H "x-api-key: $SUSTAINED_API_KEY" \
-H "X-Integration-Customer-Alias: acme" \
-H "Content-Type: application/json" \
-d '{
"skuId": { "source": "PW1002" },
"name": { "source": "Oat Drink Barista 1L" },
"amount": { "source": "1000", "target": 1000 },
"unit": { "target": "ml" },
"category": { "source": "Dairy alternatives" },
"ingredients": [
{ "name": { "source": "Oats" }, "amount": { "target": 100 }, "unit": { "target": "g" } },
{ "name": { "source": "Water" }, "amount": { "target": 880 }, "unit": { "target": "g" } }
]
}'
# 2. Poll until status is finished
curl https://api.sustained.com/impact/v1/ingest/lowres/PW1002 \
-H "x-api-key: $SUSTAINED_API_KEY" \
-H "X-Integration-Customer-Alias: acme"
# 3. Or fetch just the headline score
curl https://api.sustained.com/impact/v1/ingest/lowres/PW1002/impact-score \
-H "x-api-key: $SUSTAINED_API_KEY" \
-H "X-Integration-Customer-Alias: acme"
High-res — add packaging and distribution
Same shape, plus the fields low-res rejects:
{
"skuId": { "source": "PW1002" },
"name": { "source": "Oat Drink Barista 1L" },
"amount": { "source": "1000", "target": 1000 },
"unit": { "target": "ml" },
"category": { "source": "Dairy alternatives" },
"ingredients": [
{ "name": { "source": "Oats" }, "amount": { "target": 100 }, "unit": { "target": "g" },
"loss": { "target": 2 } }
],
"packaging": [
{ "name": { "source": "Carton 1L" },
"amount": { "target": 28 },
"unit": { "target": "g" },
"materials": [
{ "category": { "source": "Paper" }, "share": { "target": 75 } },
{ "category": { "source": "Plastic" }, "share": { "target": 25 } }
] }
],
"distributionPaths": [
{ "storageFacilities": [ { "location": { "source": "United Kingdom" } } ],
"retailFacility": { "location": { "source": "United Kingdom" } },
"consumptionLocation": { "location": { "source": "United Kingdom" } } }
]
}
Then break down what drives the result:
curl "https://api.sustained.com/impact/v1/ingest/highres/PW1002/contributors?category=pef_climate_change" \
-H "x-api-key: $SUSTAINED_API_KEY" \
-H "X-Integration-Customer-Alias: acme"
category is required. Add &includeOnlyMajor=true to prune the tree to the significant
contributors.
9. Reading a high-res report
overallImpactPoints— the headline score for one sellable unit. Lower is better.categories[]— per impact category:physical(e.g. kg CO₂e) with itsunit, plus normalisedpointsand theshareof the total.majorContributorflags the principal drivers.contributors[]— a nested tree of what drives the result.containsMajortells you a significant contributor sits somewhere beneath a node, so you can collapse the tree without hiding what matters.transportation[]— the transport legs derived from your distribution paths.consumerLabel— the consumer-facing grade, where one has been issued.reportWebUrl— the full report in the Sustained web app.methodologyVersion— results are only comparable within the same version. Store it alongside any figure you persist.
Contributor nodes that map to another product carry reference.reportApiUrl, letting you
walk into a nested product's own report.
10. Taxonomies
Four endpoints return the accepted vocabularies:
| Endpoint | Use for |
|---|---|
GET /v1/ingest/categories |
category.source on a product |
GET /v1/ingest/packaging |
packaging types, material categories and material types |
GET /v1/ingest/production-steps/food-production |
processId on a food production step |
GET /v1/ingest/production-steps/packaging-production |
processId on a packaging step |
GET /v1/ingest/packing-steps/food-packing |
processId on a packing step |
These are authoritative and change without an API version bump. Read them rather than
hard-coding — and remember you do not have to use them at all, since source accepts your
own vocabulary. They are most useful for building a mapping UI or for pre-validating input.
Processes flagged placeholder: true are usable stand-ins that contribute a generic impact
rather than a specifically modelled one.