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.

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

  1. Link a supplier — invite one by email, or link one that already registered.
  2. Ask for values — create a supplier request naming the labels you want answered.
  3. The supplier answers, in their portal. Not an API call. The request flips to complete and supplier_request.fulfilled fires.
  4. 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 id and name under 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 supplier is required everywhere.

  • Name
    status
    Type
    string
    Description

    open until the supplier submits their answers, complete afterwards. 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 is complete. 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.

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 supplierincluding 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.


GET/v1/suppliers

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

GET
/v1/suppliers
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"
    }
  ]
}

POST/v1/suppliers/invite

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

POST
/v1/suppliers/invite
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"
}

POST/v1/suppliers/link

Attaches a supplier that has already registered — through another team, say — and handed you their id out of band. Requires suppliers:write.

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

POST
/v1/suppliers/link
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)"
}

GET/v1/suppliers/:id

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

GET
/v1/suppliers/supplier_5b8e2d4f1a7c3690
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
    }
  ]
}

GET/v1/supplier_requests

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 open filter is applied after a page is read, because the stored value is pending and there is no query for it. A page can therefore come back short — or completely empty — while has_more is still true.

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

    open or complete. Omit for both.

  • Name
    cursor
    Type
    string
    Description

    A pagination cursor from a previous response's next_cursor.

Request

GET
/v1/supplier_requests
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"
}

POST/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

POST
/v1/supplier_requests
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
}

GET/v1/supplier_requests/:id

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

GET
/v1/supplier_requests/srequest_6d3b9f1e4a8c2705
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"
}

POST/v1/supplier_requests/:id/map

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

POST
/v1/supplier_requests/srequest_6d3b9f1e4a8c2705/map
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

TypeFires when
supplier.linkedPOST /v1/suppliers/link links a supplier to your team. An invitee completing onboarding creates the link without emitting this.
supplier_request.createdA request is sent to a linked supplier.
supplier_request.fulfilledThe 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

CodeStatusMeaning
supplier_already_linked409Your team is already linked to this supplier. The link is not upserted
contact_not_found400contact_email is not a contact of that supplier. Nothing is created
supplier_request_not_fulfilled409The supplier has not answered yet, so there is nothing to map
mapping_invalid400An unknown field id, two fields mapped onto one property, or a value the target property rejects. error.errors is keyed by field id
ability_missing403Mapping without passports:write scoped to the target passport's ontology
resource_missing404A 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_email and silently sends nothing when it matches no contact, returning 201 regardless. v1 resolves the contact before writing anything and rejects an unknown address with contact_not_found.

Was this page helpful?