Suppliers
A supplier is a company outside your team that you ask for data. Your team invites suppliers, links to ones that already registered, asks them for named values, and maps the answers onto passport properties.
The supplier's own portal — where they register and answer your requests — is session-based and is not part of v1. Nothing on this surface fulfils a request: fulfilment is the supplier's act, in their portal. The API covers your half of the exchange, which is everything up to the ask and everything after the answer.
Neither suppliers:read nor suppliers:write is in a key's default ability set — grant them explicitly when minting the key. The mapping endpoint requires a second ability on top of the one its route declares.
The lifecycle
- Link a supplier — invite one by email, or link one that already registered.
- Ask for values — create a supplier request naming the labels you want answered.
- The supplier answers, in their portal. Not an API call. The request flips to
completeandsupplier_request.fulfilledfires. - Map the answers onto passports — validated, coerced and written atomically.
The supplier model
- Name
object- Type
- string
- Description
Always
supplier.
- Name
id- Type
- string
- Description
Unique identifier, prefixed
supplier_. Minted when the supplier completes onboarding, and the value another team needs in order to link the same supplier.
- Name
name- Type
- string
- Description
The supplier's own company name, written once during onboarding. Not editable by the teams it works with. Nullable.
- Name
nickname- Type
- string
- Description
This team's label for the supplier. Two teams linking the same supplier see the same
idandnameunder different nicknames. Nullable.
A supplier is two things stored separately: the supplier record (id, name), written once by the supplier during their own onboarding and shared across every team that links them, and your team's link to it, whose only payload is a nickname. You cannot change name, and changing your nickname changes nothing for anyone else.
There are no timestamps on a supplier. The link is stored without one, so there is no linked_at to render, and a null field promising one would be worse than its absence. If you need to know when a link was made, record it yourself at link time.
The supplier request model
- Name
object- Type
- string
- Description
Always
supplier_request.
- Name
id- Type
- string
- Description
Unique identifier, prefixed
srequest_.
- Name
supplier- Type
- string
- Description
The supplier the request was sent to. Required again as a parameter on every endpoint that addresses a request by id — see
supplieris required everywhere.
- Name
status- Type
- string
- Description
openuntil the supplier submits their answers,completeafterwards. Mapping the answers onto a passport does not change it.
- Name
note- Type
- string
- Description
The message shown to the supplier with the request. Nullable.
- Name
fields- Type
- array
- Description
What was asked for, in order, as
{ id, label }. Ids are minted by Synex. Two fields may carry the same label and stay distinct.
- Name
fulfilled_fields- Type
- object
- Description
The supplier's answers, keyed by field id.
{}until the request iscomplete. Values are the supplier's raw text — they are validated and coerced only when mapped.
- Name
created_at- Type
- string
- Description
ISO 8601 timestamp of creation.
- Name
fulfilled_at- Type
- string
- Description
When the supplier submitted their answers. Null while
open.
There is no passport on a request. A request is never tied to one — the mapping step names its target per call and stores no link back, so one request's answers can be mapped onto several passports. Mapping onto four sibling passports is four calls and four passport.updated events, with the request unchanged throughout. If you need to know which passports a request fed, that is a record you keep.
Everything is scoped by the link
Tenancy on this surface runs entirely through the team↔supplier link. Every endpoint resolves that link first, so a supplier your team has not linked reads as 404 resource_missing — including one that plainly exists because another team linked it. The same is true of requests: a request belonging to another team, or one against a supplier you are not linked to, is a 404 rather than a 403, so the API never confirms the existence of an id it will not serve.
This is stricter than the Synex application, which will render any supplier by id whether or not the viewing team is linked to it. If you are porting logic off the app's behaviour, that is the divergence that will bite you first.
supplier is required everywhere
Every endpoint on this surface takes a supplier — including the two that already name the request in the path. GET /v1/supplier_requests/{supplier_request} and POST /v1/supplier_requests/{supplier_request}/map both require it as a query or body parameter.
The reason is storage shape, not ceremony. Requests are stored per supplier, and every team-scoped index takes the supplier as its second key component. A request id on its own therefore identifies nothing that can be looked up — only scanned. Rather than scan, the parameter is required. The precedent in v1 is path on GET /v1/ontologies/{ontology}/passports, which exists for exactly the same reason.
List suppliers
Lists the suppliers your team is linked to. Requires suppliers:read.
There is no limit on this endpoint: the underlying query pages at a fixed 25. Walk it with cursor until has_more is false.
pending_invites, first page only
This list carries one thing no other v1 list carries: a pending_invites array, present on the first page only.
An invited supplier that has not registered yet has no supplier record and no link, so it cannot be a member of the keyset the pages walk — and yet "I invited them yesterday" is the most common reason a supplier is missing from this list. Repeating an unpaginated array on every page would hand you the same invites N times, so it appears only on the page you request without a cursor.
Treat its absence as "you are past page one", never as "there are no pending invitations."
email is null on a pending invite. The invite row does not store the address — it is only ever passed to the mailer — so it is echoed on the invite response, where you just supplied it, and is null everywhere else.
An invite row disappears the moment the supplier completes onboarding, at which point they appear in data like any other linked supplier.
Optional attributes
- Name
cursor- Type
- string
- Description
A pagination cursor from a previous response's
next_cursor.
Request
curl https://api.synexcloud.com/v1/suppliers \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "list",
"data": [
{
"object": "supplier",
"id": "supplier_5b8e2d4f1a7c3690",
"name": "Nordvolt Cells",
"nickname": "Nordvolt (cells)"
},
{
"object": "supplier",
"id": "supplier_a1c4f7b0e3d68259",
"name": "Kestrel Modules AB",
"nickname": "Modules (Malmö)"
}
],
"has_more": false,
"next_cursor": null,
"url": "/v1/suppliers",
"pending_invites": [
{
"object": "supplier.invite",
"id": "317",
"email": null,
"nickname": "Nordvolt (cells)",
"created_at": "2026-07-26T09:12:40Z"
}
]
}
Invite a supplier
Emails an onboarding link to an address that is not yet a Synex supplier. Requires suppliers:write.
The onboarding token never appears in a response. Whoever holds it can complete registration as that supplier, so it is a bearer credential and it goes to the invited address or nowhere.
An invitee completing onboarding creates the link without emitting an event — the link is created in the portal, outside the API's emitting path. If you invite suppliers and need to know when they arrive, watch for them leaving pending_invites rather than waiting on an event.
Required attributes
- Name
email- Type
- string
- Description
Where to send the onboarding link.
Optional attributes
- Name
nickname- Type
- string
- Description
Your team's label for the supplier, applied when the link is created.
Request
curl https://api.synexcloud.com/v1/suppliers/invite \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "quality@nordvolt.example",
"nickname": "Nordvolt (cells)"
}'
Response
{
"object": "supplier.invite",
"id": "317",
"email": "quality@nordvolt.example",
"nickname": "Nordvolt (cells)",
"created_at": "2026-07-26T09:12:40Z"
}
Link a supplier
Attaches a supplier that has already registered — through another team, say — and handed you their id out of band. Requires suppliers:write.
Knowing the supplier's id is what authorises the link. There is no acceptance step, no notification to the supplier, and no way for them to refuse. The id is the capability. Treat it as one.
Linking emits supplier.linked. A second attempt at a link you already hold is 409 supplier_already_linked — the link is not upserted.
Required attributes
- Name
supplier- Type
- string
- Description
The supplier id to link.
Optional attributes
- Name
nickname- Type
- string
- Description
Your team's label for the supplier.
Request
curl https://api.synexcloud.com/v1/suppliers/link \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"supplier": "supplier_5b8e2d4f1a7c3690",
"nickname": "Nordvolt (cells)"
}'
Response
{
"object": "supplier",
"id": "supplier_5b8e2d4f1a7c3690",
"name": "Nordvolt Cells",
"nickname": "Nordvolt (cells)"
}
Retrieve a supplier
Reads one linked supplier with its contacts and the first page of your open requests against it. Requires suppliers:read.
Contacts are maintained by the supplier in their own portal and are read-only here — they are the addresses contact_email may name when you create a request.
open_requests is a convenience. The authoritative, walkable list is GET /v1/supplier_requests.
Request
curl https://api.synexcloud.com/v1/suppliers/supplier_5b8e2d4f1a7c3690 \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "supplier",
"id": "supplier_5b8e2d4f1a7c3690",
"name": "Nordvolt Cells",
"nickname": "Nordvolt (cells)",
"contacts": [
{
"object": "supplier.contact",
"email": "quality@nordvolt.example",
"name": "Ana Ruiz",
"phone": "+46 8 555 0142"
}
],
"open_requests": [
{
"object": "supplier_request",
"id": "srequest_0e5c8a2f7b3d1946",
"supplier": "supplier_5b8e2d4f1a7c3690",
"status": "open",
"note": "Batch 24-10 traceability, please.",
"fields": [
{ "id": "field_c3f6a0d8b1e47592", "label": "Batch reference" }
],
"fulfilled_fields": {},
"created_at": "2026-07-24T11:05:18Z",
"fulfilled_at": null
}
]
}
List supplier requests
Lists requests against one supplier. Requires suppliers:read.
One status filter — open, complete, or omitted for both — unifies what the application splits across two separate screens.
Two pagination notes, both of which will surprise you if you assume the standard envelope behaves standardly here:
- There is no
limit. The underlying model pages at a fixed 25. The endpoint has a cursor but no page size. - The
openfilter is applied after a page is read, because the stored value ispendingand there is no query for it. A page can therefore come back short — or completely empty — whilehas_moreis stilltrue.
Page until has_more is false. Never terminate on a short page. The cursor is scoped to the filters that produced it, so keep supplier and status identical for the whole walk; see pagination.
Required attributes
- Name
supplier- Type
- string
- Description
The supplier whose requests to list.
Optional attributes
- Name
status- Type
- string
- Description
openorcomplete. Omit for both.
- Name
cursor- Type
- string
- Description
A pagination cursor from a previous response's
next_cursor.
Request
curl -G https://api.synexcloud.com/v1/supplier_requests \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-d "supplier=supplier_5b8e2d4f1a7c3690" \
-d "status=open"
Response
{
"object": "list",
"data": [
{
"object": "supplier_request",
"id": "srequest_0e5c8a2f7b3d1946",
"supplier": "supplier_5b8e2d4f1a7c3690",
"status": "open",
"note": "Batch 24-10 traceability, please.",
"fields": [
{ "id": "field_c3f6a0d8b1e47592", "label": "Batch reference" }
],
"fulfilled_fields": {},
"created_at": "2026-07-24T11:05:18Z",
"fulfilled_at": null
}
],
"has_more": false,
"next_cursor": null,
"url": "/v1/supplier_requests"
}
Create a supplier request
Asks a linked supplier for a set of named values. Requires suppliers:write. Emits supplier_request.created and starts at status: "open".
fields is a list of labels — the questions you want answered. You do not supply ids: Synex mints a field_ id per label and returns them on the created request, as fields[].id beside fields[].label. The portal keys the supplier's answers by those ids and the mapping step keys its mappings by them, so they have to be unique per request, which is not something a public API should trust a client to get right.
A consequence worth knowing: two fields may carry the same label and stay distinct. Asking twice for "Cell chemistry" gives you two ids and two answer slots.
contact_email must name a contact of that supplier, or the call is 400 contact_not_found and nothing is created.
Required attributes
- Name
supplier- Type
- string
- Description
The linked supplier to ask.
- Name
fields- Type
- array
- Description
The labels you want answered.
Optional attributes
- Name
note- Type
- string
- Description
A message shown to the supplier with the request.
- Name
contact_email- Type
- string
- Description
Which of the supplier's contacts to address. Must be one of theirs.
Request
curl https://api.synexcloud.com/v1/supplier_requests \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"supplier": "supplier_5b8e2d4f1a7c3690",
"fields": ["Measured capacity (kWh)", "Cell chemistry"],
"note": "Q3 conformity pack",
"contact_email": "quality@nordvolt.example"
}'
Response
{
"object": "supplier_request",
"id": "srequest_6d3b9f1e4a8c2705",
"supplier": "supplier_5b8e2d4f1a7c3690",
"status": "open",
"note": "Q3 conformity pack",
"fields": [
{
"id": "field_2a8d6c0f9e3b4715",
"label": "Measured capacity (kWh)"
},
{ "id": "field_7b1e4d9a2c6f8053", "label": "Cell chemistry" }
],
"fulfilled_fields": {},
"created_at": "2026-07-26T09:40:11Z",
"fulfilled_at": null
}
Retrieve a supplier request
Reads one request. Requires suppliers:read, and the supplier query parameter even though the path already names the request — see above.
When the supplier submits in the portal, status flips to complete, fulfilled_fields fills in as a map from field id to their answer, and fulfilled_at is set. supplier_request.fulfilled is emitted for your team, with a null actor — the supplier is not a member of your team, so the recorded actor is correctly nobody rather than misleadingly somebody.
That event is the thing to drive an integration off. Polling this endpoint works too, and costs you a call per tick instead of an endpoint.
Required attributes
- Name
supplier- Type
- string
- Description
The supplier the request belongs to.
Request
curl -G https://api.synexcloud.com/v1/supplier_requests/srequest_6d3b9f1e4a8c2705 \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-d "supplier=supplier_5b8e2d4f1a7c3690"
Response
{
"object": "supplier_request",
"id": "srequest_6d3b9f1e4a8c2705",
"supplier": "supplier_5b8e2d4f1a7c3690",
"status": "complete",
"note": "Q3 conformity pack",
"fields": [
{
"id": "field_2a8d6c0f9e3b4715",
"label": "Measured capacity (kWh)"
},
{ "id": "field_7b1e4d9a2c6f8053", "label": "Cell chemistry" }
],
"fulfilled_fields": {
"field_2a8d6c0f9e3b4715": "12,5",
"field_7b1e4d9a2c6f8053": "LFP"
},
"created_at": "2026-07-26T09:40:11Z",
"fulfilled_at": "2026-07-27T16:22:03Z"
}
Map answers onto a passport
Turns a supplier's answers into passport values. This is the most consequential call on this surface and the one with the most rules.
It requires two abilities
The route is declared with suppliers:write. The handler additionally requires passports:write, ontology-scoped to the target passport's ontology exactly as PATCH /v1/passports/{passport} scopes it. A key holding only suppliers:write gets 403 ability_missing.
That is deliberate: writing passport values through a supplier endpoint is still writing passport values. A key that cannot reach a passport any other way must not be able to reach one this way.
Validation is real, and the write is atomic
mappings is an object of field id → property id. Each answer is validated against the passport's pinned ontology version and coerced to the property's declared type, and the whole set lands in one write — all of them or none.
A single invalid value fails the call with 400 mapping_invalid and stores nothing. Errors are re-keyed back to the field ids you sent, so error.errors names the half of the mapping you wrote rather than the property ids you were mapping onto.
The same 400 mapping_invalid also covers two shape errors, caught before anything is resolved: a field the request has no answer for, and two fields mapped onto the same property.
This is stricter than the application, which writes each answer straight through without validating it. There, "about 40kg" lands in a number property untouched. v1 will not do that.
The request has to be fulfilled first
Mapping a request the supplier has not answered yet is 409 supplier_request_not_fulfilled. Wait for supplier_request.fulfilled, or poll until status reads complete.
Mapping emits passport.updated, not a supplier event, and does not close the request.
Required attributes
- Name
supplier- Type
- string
- Description
The supplier the request belongs to.
- Name
passport- Type
- string
- Description
The passport to write onto.
- Name
mappings- Type
- object
- Description
Field id → property id.
Request
curl https://api.synexcloud.com/v1/supplier_requests/srequest_6d3b9f1e4a8c2705/map \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"supplier": "supplier_5b8e2d4f1a7c3690",
"passport": "pass_3e9a1c5b7d2f4083",
"mappings": {
"field_2a8d6c0f9e3b4715": "property_capacity",
"field_7b1e4d9a2c6f8053": "property_chemistry"
}
}'
Response
{
"object": "supplier_request.mapping",
"supplier_request": "srequest_6d3b9f1e4a8c2705",
"supplier": "supplier_5b8e2d4f1a7c3690",
"passport": "pass_3e9a1c5b7d2f4083",
"mapped": {
"field_2a8d6c0f9e3b4715": "property_capacity",
"field_7b1e4d9a2c6f8053": "property_chemistry"
},
"values": {
"property_capacity": 12.5,
"property_chemistry": "LFP"
}
}
mapped echoes what you sent. values is keyed by property id and holds each value as coerced — so you can see what a "yes" or a "12,5" actually became without re-reading the passport. Check it rather than assuming; coercion is where a supplier's formatting habits surface.
Audit provenance: be aware of the gap
Mapping's audit-log entry is the plain passport modify entry. It records that the values changed and who made the call — it does not record that they came from a supplier request. Nothing in the audit log will tell you a value arrived from Nordvolt rather than from a PATCH.
That is a real limitation rather than an oversight: the audit trail's sort key is a whole unix second, so attaching a second, provenance-carrying entry to the same write risks a key collision with the entry that actually matters.
If you need supplier provenance today, correlate on the change feed instead. supplier_request.fulfilled and the passport.updated your mapping call produced are linked by the request id in your own records, and the mapping response tells you exactly which properties moved.
Events
| Type | Fires when |
|---|---|
supplier.linked | POST /v1/suppliers/link links a supplier to your team. An invitee completing onboarding creates the link without emitting this. |
supplier_request.created | A request is sent to a linked supplier. |
supplier_request.fulfilled | The supplier submits their answers in the portal. Emitted for the requesting team, with a null actor. |
Mapping emits passport.updated, not a supplier event, because the passport is the object that changed.
Errors
| Code | Status | Meaning |
|---|---|---|
supplier_already_linked | 409 | Your team is already linked to this supplier. The link is not upserted |
contact_not_found | 400 | contact_email is not a contact of that supplier. Nothing is created |
supplier_request_not_fulfilled | 409 | The supplier has not answered yet, so there is nothing to map |
mapping_invalid | 400 | An unknown field id, two fields mapped onto one property, or a value the target property rejects. error.errors is keyed by field id |
ability_missing | 403 | Mapping without passports:write scoped to the target passport's ontology |
resource_missing | 404 | A supplier your team has not linked, or a request that is not yours — whether or not it exists |
Two of those correct application behaviour rather than mirroring it, and both matter if you are used to the app:
- The application answers 404 for a duplicate link, which says the opposite of what happened — "no such supplier", when the supplier is right there and already yours. v1 answers 409.
- The application accepts any
contact_emailand silently sends nothing when it matches no contact, returning 201 regardless. v1 resolves the contact before writing anything and rejects an unknown address withcontact_not_found.