# Propeller Commerce Documentation — Full Content > Complete text of all hand-written Propeller Commerce documentation. Auto-generated API reference pages (GraphQL schema, REST endpoints) are excluded — use the APIs directly for schema introspection. ## Authentication and Authorization URL: https://docs.propeller-commerce.com/frontend/domain-guides/accounts-and-authentication/authentication-and-authorization # Authentication and authorization Register companies and contacts (B2B), register customers (B2C), log in, manage access tokens and implement session handling using Propeller's GraphQL API. ## API key setup Every request to the GraphQL API requires an `apiKey` header. Propeller provides two key types: - **System API key** has full access and is for server-side use only. Never expose this in frontend code. - **Limited API key** provides anonymous access for frontend use. Supports browsing products, creating carts and registering users. ```bash curl https://api.propellercommerce.com/graphql \ -X POST \ -H "Content-Type: application/json" \ -H "apiKey: YOUR_LIMITED_API_KEY" \ -d '{"query": "{ viewer { __typename } }"}' ``` Once a user logs in, add the access token alongside the API key: ```bash curl https://api.propellercommerce.com/graphql \ -X POST \ -H "Content-Type: application/json" \ -H "apiKey: YOUR_LIMITED_API_KEY" \ -H "Authorization: Bearer ACCESS_TOKEN" \ -d '{"query": "{ viewer { firstName lastName email } }"}' ``` ## Registering a company and contact (B2B) B2B registration is a two-step process: first create the company, then register a contact within it. ### Step 1: Create the company ```graphql mutation { companyCreate( input: { name: "Acme Corp" taxNumber: "NL123456789B01" } ) { companyId name } } ``` **Expected response:** ```json { "data": { "companyCreate": { "companyId": 456, "name": "Acme Corp" } } } ``` ### Step 2: Register a contact Use the `companyId` from step 1 as the `parentId`: ```graphql mutation { contactRegister( input: { firstName: "Jane" lastName: "Smith" email: "jane@acme.com" phone: "020-7654321" password: "secure_password" parentId: 456 } ) { contact { userId firstName lastName email } session { accessToken refreshToken expirationTime } } } ``` **Expected response:** ```json { "data": { "contactRegister": { "contact": { "userId": 789, "firstName": "Jane", "lastName": "Smith", "email": "jane@acme.com" }, "session": { "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "AOEOulYnMD7FqivLPCPy2w3bXn...", "expirationTime": "2026-02-25T15:00:00.000Z" } } } } ``` ## Registering a customer (B2C) Use `customerRegister` to create a B2C customer account. The mutation returns a session with access and refresh tokens, so the user is immediately logged in. ```graphql mutation { customerRegister( input: { firstName: "John" lastName: "Doe" email: "john@example.com" phone: "020-1234567" password: "secure_password" } ) { session { accessToken refreshToken expirationTime } customer { ... on Customer { customerId firstName lastName email } } } } ``` **Expected response:** ```json { "data": { "customerRegister": { "session": { "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "AOEOulYnMD7FqivLPCPy2w3bXn...", "expirationTime": "2026-02-25T15:00:00.000Z" }, "customer": { "customerId": 12345, "firstName": "John", "lastName": "Doe", "email": "john@example.com" } } } } ``` ## Logging in Use the `login` mutation to authenticate an existing user (works for both customers and contacts): ```graphql mutation { login( input: { email: "john@example.com" password: "secure_password" } ) { session { accessToken refreshToken expirationTime } } } ``` **Expected response:** ```json { "data": { "login": { "session": { "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "AOEOulYnMD7FqivLPCPy2w3bXn...", "expirationTime": "2026-02-25T15:00:00.000Z" } } } } ``` The response contains: | Field | Description | |---|---| | `accessToken` | Bearer token for authenticated API requests. Expires after 1 hour. | | `refreshToken` | Token to obtain new access tokens. Does not expire unless the user is deleted, disabled or changes their password/email. | | `expirationTime` | Timestamp when the access token expires. | ## The viewer query After logging in, use `viewer` to retrieve the current user's details: ```graphql query { viewer { __typename firstName middleName lastName email phone mobile isLoggedIn ... on Contact { userId: contactId company { companyId name addresses { id street number city postalCode country type isDefault } } } ... on Customer { userId: customerId addresses { id street number city postalCode country type isDefault } } } } ``` **Expected response (for a contact):** ```json { "data": { "viewer": { "__typename": "Contact", "firstName": "Jane", "middleName": null, "lastName": "Smith", "email": "jane@acme.com", "phone": "020-7654321", "mobile": null, "isLoggedIn": true, "userId": 789, "company": { "companyId": 456, "name": "Acme Corp", "addresses": [ { "id": 101, "street": "Keizersgracht", "number": "100", "city": "Amsterdam", "postalCode": "1015 AA", "country": "NL", "type": "delivery", "isDefault": "Y" } ] } } } } ``` The response type depends on who is logged in. A `Contact` is a B2B user where addresses come from the company. A `Customer` is a B2C user where addresses are on the customer directly. Use `__typename` to determine which type and render the appropriate UI. ## Token lifecycle ### Refreshing tokens When the access token expires, use `exchangeRefreshToken` to get a new one: ```graphql mutation { exchangeRefreshToken( input: { refreshToken: "your_refresh_token" } ) { access_token refresh_token expires_in token_type user_id } } ``` **Expected response:** ```json { "data": { "exchangeRefreshToken": { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "AOEOulYnMD7FqivLPCPy2w3bXn...", "expires_in": 3600, "token_type": "Bearer", "user_id": "12345" } } } ``` This returns a new access token and refresh token. Store them and continue making authenticated requests. ### Token expiry details | Token | Lifetime | |---|---| | Access token | 3600 seconds (1 hour) | | Refresh token | Does not expire | Refresh tokens are invalidated when: - The user is deleted - The user is disabled - The user changes their password or email ## Password reset Generate a password reset link with `passwordResetLink`: ```graphql mutation { passwordResetLink( input: { email: "john@example.com" redirectUrl: "https://yourstore.com/login" language: "en" } ) } ``` On success, this returns a reset link that you can include in a password reset email. The `redirectUrl` is where the user will be redirected after resetting their password. ## Logging out There is no explicit logout mutation. To log a user out: 1. Remove the stored access token and refresh token from your client-side storage 2. Optionally start a new anonymous session ## Next steps - [Understanding companies, contacts and customers](/frontend/domain-guides/accounts-and-authentication/understanding-companies-contacts-and-customers) for how companies, contacts and customers relate - [Managing addresses](/frontend/domain-guides/accounts-and-authentication/managing-addresses) to manage customer and company addresses - [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow) to use viewer data to pre-fill checkout addresses --- ## Favorite Lists URL: https://docs.propeller-commerce.com/frontend/domain-guides/accounts-and-authentication/favorite-lists # Favorite lists Create, manage and display favorite lists using Propeller's GraphQL API. Favorite lists let users save products and clusters for quick access and reordering. A favorite list belongs to one of three owner types: | Owner | Field | Behavior | |---|---|---| | Company | `companyId` | Shared list, visible to all contacts in the company | | Contact | `contactId` | Personal list for a specific B2B contact | | Customer | `customerId` | Personal list for a B2C customer | ## Fetching favorite lists Use the `favoriteLists` query to retrieve lists for a specific owner. Filter by `companyId`, `contactId` or `customerId` to scope the results. ```graphql query GetFavoriteLists($companyId: Int, $page: Int, $offset: Int) { favoriteLists( input: { companyId: $companyId page: $page offset: $offset } ) { items { id name companyId contactId customerId isDefault slug createdAt updatedAt products { itemsFound } clusters { itemsFound } } itemsFound page pages } } ``` **Variables:** ```json { "companyId": 456, "page": 1, "offset": 10 } ``` **Expected response:** ```json { "data": { "favoriteLists": { "items": [ { "id": "689615cf72635ff51c3471eb", "name": "Standard office supplies", "companyId": 456, "contactId": null, "customerId": null, "isDefault": true, "slug": "standard-office-supplies", "createdAt": "2026-01-15T09:30:00.000Z", "updatedAt": "2026-02-20T14:22:00.000Z", "products": { "itemsFound": 8 }, "clusters": { "itemsFound": 2 } }, { "id": "69a17974f21ab41940993034", "name": "Warehouse equipment", "companyId": 456, "contactId": null, "customerId": null, "isDefault": false, "slug": "warehouse-equipment", "createdAt": "2026-02-10T11:00:00.000Z", "updatedAt": "2026-02-27T11:01:31.000Z", "products": { "itemsFound": 3 }, "clusters": { "itemsFound": 1 } } ], "itemsFound": 2, "page": 1, "pages": 1 } } } ``` The search input also supports filtering by `name`, `isDefault`, `productIds` (find lists containing specific products), `clusterIds`, `createdAt` and `lastModifiedAt` (with date range filters). ## Fetching a single list with items Use the `favoriteList` query to retrieve a single list by ID, including its products and clusters: ```graphql query GetFavoriteList($id: String!) { favoriteList(id: $id) { id name isDefault slug products { items { ... on Product { productId names { language value } sku media { images(search: { sort: ASC, offset: 1 }) { items { imageVariants( input: { transformations: [ { name: "thumbnail" transformation: { width: 100 height: 100 fit: BOUNDS bgColor: "transparent" canvas: { width: 100, height: 100 } } } ] } ) { name url } } } } } } itemsFound } clusters { items { ... on Cluster { clusterId names { language value } sku defaultProduct { media { images(search: { sort: ASC, offset: 1 }) { items { imageVariants( input: { transformations: [ { name: "thumbnail" transformation: { width: 100 height: 100 fit: BOUNDS bgColor: "transparent" canvas: { width: 100, height: 100 } } } ] } ) { name url } } } } } } } itemsFound } } } ``` **Expected response:** ```json { "data": { "favoriteList": { "id": "69a17974f21ab41940993034", "name": "Warehouse equipment", "isDefault": false, "slug": "warehouse-equipment", "products": { "items": [ { "productId": 104708, "names": [ { "language": "NL", "value": "Elektrische Palletwagen EP-116" } ], "sku": "EP-116", "media": { "images": { "items": [ { "imageVariants": [ { "name": "thumbnail", "url": "https://media.helice.cloud/example/images/en/cb853191-palletwagen.jpg?bg-color=transparent&canvas=100,100&fit=bounds&height=100&width=100" } ] } ] } } } ], "itemsFound": 3 }, "clusters": { "items": [ { "clusterId": 37952, "names": [ { "language": "NL", "value": "Magazijnstelling Pro 200" } ], "sku": "MAG-PRO-200", "defaultProduct": { "media": { "images": { "items": [ { "imageVariants": [ { "name": "thumbnail", "url": "https://media.helice.cloud/example/images/nl/3ecf9984-stelling.png?bg-color=transparent&canvas=100,100&fit=bounds&height=100&width=100" } ] } ] } } } } ], "itemsFound": 1 } } } } ``` Favorite lists contain both products and clusters as separate collections. Use inline fragments (`... on Product` and `... on Cluster`) to access their fields. Both collections support their own pagination through the `input` argument. For cluster images, access the media through `defaultProduct.media` since clusters do not have a media field directly. > Favorite lists show products from all catalogs by default. In multi-store setups with different catalog root IDs, filter the products in the favorite list query by passing the catalog root ID to exclude products outside the current store's catalog. ## Creating a favorite list Use `favoriteListCreate` to create a new list. Set one of `companyId`, `contactId` or `customerId` to assign ownership. **For a company (shared list):** ```graphql mutation { favoriteListCreate( input: { companyId: 456 name: "Warehouse equipment" isDefault: false } ) { id name } } ``` **Expected response:** ```json { "data": { "favoriteListCreate": { "id": "69a17974f21ab41940993034", "name": "Warehouse equipment" } } } ``` **For a contact (personal B2B list):** ```graphql mutation { favoriteListCreate( input: { contactId: 312 name: "Lisa's project list" isDefault: false } ) { id name } } ``` **For a customer (B2C list):** ```graphql mutation { favoriteListCreate( input: { customerId: 789 name: "Wishlist" isDefault: false } ) { id name } } ``` The `isDefault` field defaults to `false`. Setting it to `true` marks this as the primary list for the owner. You can optionally include `productIds` and `clusterIds` in the create input to pre-populate the list with items on creation. ## Adding products and clusters Use `favoriteListAddItems` to add products, clusters or both to an existing list. Items are appended to the list. **Adding products:** ```graphql mutation { favoriteListAddItems( id: "69a17974f21ab41940993034" input: { productIds: [104708, 104923] } ) { id products { itemsFound } clusters { itemsFound } } } ``` **Expected response:** ```json { "data": { "favoriteListAddItems": { "id": "69a17974f21ab41940993034", "products": { "itemsFound": 2 }, "clusters": { "itemsFound": 0 } } } } ``` **Adding clusters:** ```graphql mutation { favoriteListAddItems( id: "69a17974f21ab41940993034" input: { clusterIds: [37952] } ) { id products { itemsFound } clusters { itemsFound } } } ``` ```json { "data": { "favoriteListAddItems": { "id": "69a17974f21ab41940993034", "products": { "itemsFound": 0 }, "clusters": { "itemsFound": 1 } } } } ``` You can add products and clusters in the same call by providing both `productIds` and `clusterIds`. The mutation returns the updated favorite list, so you can query the full product and cluster details in the response to refresh your UI immediately. ## Removing items from a list Use `favoriteListRemoveItems` to remove specific products or clusters from a list: ```graphql mutation { favoriteListRemoveItems( id: "69a17974f21ab41940993034" input: { productIds: [104923] } ) { id products { itemsFound } } } ``` **Expected response:** ```json { "data": { "favoriteListRemoveItems": { "id": "69a17974f21ab41940993034", "products": { "itemsFound": 1 } } } } ``` The input accepts the same structure as `favoriteListAddItems`. You can remove products, clusters or both in one call. ## Updating a favorite list Use `favoriteListUpdate` to change the name or default status of a list: ```graphql mutation { favoriteListUpdate( id: "69a17974f21ab41940993034" input: { name: "Main warehouse supplies" isDefault: true } ) { id name slug isDefault } } ``` **Expected response:** ```json { "data": { "favoriteListUpdate": { "id": "69a17974f21ab41940993034", "name": "Main warehouse supplies", "slug": "main-warehouse-supplies", "isDefault": true } } } ``` The slug is automatically generated from the name. The update input also accepts `productIds` and `clusterIds`. When provided, these replace the entire product or cluster list. This is different from `favoriteListAddItems` which appends items. Only send these fields if you intend to replace the full list contents. ## Clearing all items Use `favoriteListClearItems` to remove all products and clusters from a list without deleting the list itself: ```graphql mutation { favoriteListClearItems(id: "69a17974f21ab41940993034") { id name products { itemsFound } clusters { itemsFound } } } ``` **Expected response:** ```json { "data": { "favoriteListClearItems": { "id": "69a17974f21ab41940993034", "name": "Main warehouse supplies", "products": { "itemsFound": 0 }, "clusters": { "itemsFound": 0 } } } } ``` ## Deleting a favorite list Use `favoriteListDelete` to permanently remove a list and all its items: ```graphql mutation { favoriteListDelete(id: "69a17974f21ab41940993034") } ``` ```json { "data": { "favoriteListDelete": true } } ``` ## Next steps - [Understanding companies, contacts and customers](/frontend/domain-guides/accounts-and-authentication/understanding-companies-contacts-and-customers) for how the ownership model works - [Querying products](/frontend/domain-guides/products-and-catalog/querying-products) to find products to add to favorite lists - [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) to add products from favorite lists to a cart - [Fetch favorite lists with products](/frontend/recipes/fetch-favorite-lists) for a ready-to-use recipe - [Add or remove a product from a favorite list](/frontend/recipes/manage-favorite-list-items) for a ready-to-use recipe --- ## Managing Addresses URL: https://docs.propeller-commerce.com/frontend/domain-guides/accounts-and-authentication/managing-addresses # Managing addresses Retrieve, create, update and delete addresses for customers and companies using Propeller's GraphQL API. ## Retrieving addresses for the logged-in user Use the `viewer` query to retrieve addresses for the currently authenticated user. Contacts access addresses through their company. Customers have addresses directly on their account. ```graphql query { viewer { __typename ... on Contact { contactId company { companyId name addresses { id firstName lastName company street number numberExtension postalCode city country phone email type isDefault icp notes } } } ... on Customer { customerId addresses { id firstName lastName street number numberExtension postalCode city country phone email type isDefault notes } } } } ``` **Expected response (for a contact):** ```json { "data": { "viewer": { "__typename": "Contact", "contactId": 312, "company": { "companyId": 456, "name": "Brouwer Industrie", "addresses": [ { "id": 101, "firstName": "Lisa", "lastName": "de Vries", "company": "Brouwer Industrie", "street": "Herengracht", "number": "42", "numberExtension": null, "postalCode": "1015 BN", "city": "Amsterdam", "country": "NL", "phone": "020-5551234", "email": "info@brouwerindustrie.nl", "type": "delivery", "isDefault": "Y", "icp": "N", "notes": null }, { "id": 102, "firstName": "Lisa", "lastName": "de Vries", "company": "Brouwer Industrie", "street": "Industrieweg", "number": "88", "numberExtension": null, "postalCode": "5651 GK", "city": "Eindhoven", "country": "NL", "phone": "040-5559876", "email": "eindhoven@brouwerindustrie.nl", "type": "delivery", "isDefault": "N", "icp": "N", "notes": "Use entrance B at the back of the building" }, { "id": 103, "firstName": "Lisa", "lastName": "de Vries", "company": "Brouwer Industrie", "street": "Herengracht", "number": "42", "numberExtension": null, "postalCode": "1015 BN", "city": "Amsterdam", "country": "NL", "phone": "020-5551234", "email": "finance@brouwerindustrie.nl", "type": "invoice", "isDefault": "Y", "icp": "N", "notes": null } ] } } } } ``` The `addresses` field on both `Company` and `Customer` accepts two optional filters: | Filter | Description | |---|---| | `type` | Filter by address type: `delivery` or `invoice`. Omit to return all types. | | `isDefault` | Filter by default status: `Y` or `N`. | For example, to retrieve only the default delivery address: ```graphql addresses(type: delivery, isDefault: Y) { id street number city postalCode country } ``` ## Querying addresses directly Use `addressesByCompanyId` or `addressesByCustomerId` to query addresses without going through the viewer. The optional `type` parameter filters by address type. ```graphql query { addressesByCompanyId(companyId: 456, type: delivery) { id firstName lastName company street number numberExtension postalCode city country phone email type isDefault icp } } ``` **Expected response:** ```json { "data": { "addressesByCompanyId": [ { "id": 101, "firstName": "Lisa", "lastName": "de Vries", "company": "Brouwer Industrie", "street": "Herengracht", "number": "42", "numberExtension": null, "postalCode": "1015 BN", "city": "Amsterdam", "country": "NL", "phone": "020-5551234", "email": "info@brouwerindustrie.nl", "type": "delivery", "isDefault": "Y", "icp": "N" }, { "id": 102, "firstName": "Lisa", "lastName": "de Vries", "company": "Brouwer Industrie", "street": "Industrieweg", "number": "88", "numberExtension": null, "postalCode": "5651 GK", "city": "Eindhoven", "country": "NL", "phone": "040-5559876", "email": "eindhoven@brouwerindustrie.nl", "type": "delivery", "isDefault": "N", "icp": "N" } ] } } ``` Omit the `type` parameter to return all addresses regardless of type. `addressesByCustomerId` works the same way, with `customerId` instead of `companyId`. ## Creating a company address Use `companyAddressCreate` to add an address to a company: ```graphql mutation { companyAddressCreate( input: { companyId: 456 type: delivery firstName: "Tom" lastName: "Hendriks" company: "Brouwer Industrie" street: "Prinsengracht" number: "200" postalCode: "1016 HC" city: "Amsterdam" country: "NL" phone: "020-5554567" email: "amsterdam-west@brouwerindustrie.nl" isDefault: N notes: "Ring bell twice" } ) { id firstName lastName company street number postalCode city country phone email type isDefault } } ``` **Expected response:** ```json { "data": { "companyAddressCreate": { "id": 104, "firstName": "Tom", "lastName": "Hendriks", "company": "Brouwer Industrie", "street": "Prinsengracht", "number": "200", "postalCode": "1016 HC", "city": "Amsterdam", "country": "NL", "phone": "020-5554567", "email": "amsterdam-west@brouwerindustrie.nl", "type": "delivery", "isDefault": "N" } } } ``` The required fields are: | Field | Description | |---|---| | `companyId` | The company to add the address to | | `type` | Address type: `delivery` or `invoice`. | | `street` | Street name | | `postalCode` | Postal code | | `city` | City | | `country` | Country code (e.g. `NL`, `DE`, `BE`) | All other fields are optional. When `isDefault` is omitted, the address is not set as default. Setting `isDefault` to `Y` makes this the default address for its type within the company. The `icp` field controls whether tax is applied when this address is selected as a delivery address. Set it to `Y` for intra-community B2B shipments within the EU where the reverse charge mechanism applies. ## Creating a customer address Use `customerAddressCreate` to add an address to a customer account: ```graphql mutation { customerAddressCreate( input: { customerId: 789 type: delivery firstName: "Sophie" lastName: "van Dijk" street: "Vondelstraat" number: "15" numberExtension: "A" postalCode: "1054 GD" city: "Amsterdam" country: "NL" phone: "020-5557890" email: "sophie@example.com" } ) { id firstName lastName street number numberExtension postalCode city country phone email type isDefault } } ``` **Expected response:** ```json { "data": { "customerAddressCreate": { "id": 201, "firstName": "Sophie", "lastName": "van Dijk", "street": "Vondelstraat", "number": "15", "numberExtension": "A", "postalCode": "1054 GD", "city": "Amsterdam", "country": "NL", "phone": "020-5557890", "email": "sophie@example.com", "type": "delivery", "isDefault": "N" } } } ``` The required fields are the same as for company addresses: `customerId`, `type`, `street`, `postalCode`, `city` and `country`. ## Updating an address Use `companyAddressUpdate` to modify an existing company address. Only send the fields you want to change. Fields you omit keep their current values. ```graphql mutation { companyAddressUpdate( input: { id: 102 companyId: 456 street: "Kanaaldijk" number: "12" postalCode: "5683 CR" city: "Best" } ) { id street number postalCode city country type isDefault } } ``` **Expected response:** ```json { "data": { "companyAddressUpdate": { "id": 102, "street": "Kanaaldijk", "number": "12", "postalCode": "5683 CR", "city": "Best", "country": "NL", "type": "delivery", "isDefault": "N" } } } ``` The `id` and `companyId` fields are required. All other fields are optional. The `type` field cannot be changed after creation. To change an address from delivery to invoice, delete it and create a new one with the correct type. `customerAddressUpdate` works the same way, using `customerId` instead of `companyId`. ## Deleting an address Use `companyAddressDelete` to remove a company address: ```graphql mutation { companyAddressDelete( input: { id: 104 companyId: 456 } ) } ``` ```json { "data": { "companyAddressDelete": true } } ``` `customerAddressDelete` works the same way, using `customerId` instead of `companyId`. ## Handling both user types Your frontend needs to handle both contacts (B2B) and customers (B2C). Use the `__typename` from the `viewer` query to determine which mutations to call: | User type | `__typename` | Mutations | Identifier | |---|---|---|---| | Contact (B2B) | `Contact` | `companyAddressCreate`, `companyAddressUpdate`, `companyAddressDelete` | `companyId` from `viewer.company` | | Customer (B2C) | `Customer` | `customerAddressCreate`, `customerAddressUpdate`, `customerAddressDelete` | `customerId` from `viewer` | Contacts manage addresses at the company level. Any changes to company addresses are visible to all contacts within that company. ## Next steps - [Understanding companies, contacts and customers](/frontend/domain-guides/accounts-and-authentication/understanding-companies-contacts-and-customers) for how companies, contacts and customers relate - [Authentication and authorization](/frontend/domain-guides/accounts-and-authentication/authentication-and-authorization) for registration, login and token management - [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow) to use stored addresses during checkout --- ## Understanding Companies, Contacts and Customers URL: https://docs.propeller-commerce.com/frontend/domain-guides/accounts-and-authentication/understanding-companies-contacts-and-customers # Understanding companies, contacts and customers Propeller uses three account entities to model B2B and B2C relationships. **Companies** and **contacts** are used for B2B. **Customers** are used for B2C. Understanding how they relate helps you build registration, checkout and account management features correctly. ## Companies A company is the B2B entity, identified by `companyId`. A company represents a business organization with its own financial details, addresses and users. | Field | Description | |---|---| | `name` | Company name | | `email`, `phone` | Company contact details | | `taxNumber` | VAT / tax registration number | | `cocNumber` | Chamber of Commerce number | | `debtorId` | Financial identifier for invoicing | | `notes` | Internal notes | | `slug` | URL-friendly identifier | | `tag` | Custom tag for grouping or classification | | `hidden` | Whether the company should only be used for background operations (Y/N) | A company has **contacts** (the people who operate within it), **addresses** (delivery and invoice) and optionally **managers** (account managers assigned to the company). ## Contacts A contact is a B2B user entity, identified by `contactId`. A contact is a person who operates within the context of one or more companies. Contacts carry personal fields (firstName, middleName, lastName, email, phone, mobile, gender, dateOfBirth, primaryLanguage, login) plus B2B-specific fields: | Field | Description | |---|---| | `parentCompanyId` | The primary company this contact belongs to | | `company` | The primary company entity | | `companies` | All companies the contact belongs to | | `managedCompanies` | Companies where this contact is an account manager | ### Contacts in multiple companies A contact can belong to multiple companies. Each company relationship is independent, with its own purchase authorization role and spending limit. For example, a single contact might belong to three companies: ``` Contact: Lisa de Vries ├── Brouwer Industrie NL (AUTHORIZATION_MANAGER, no spending limit) ├── Brouwer Industrie DE └── Brouwer Industrie US ``` When a contact places an order, the order is associated with both the contact and the company they are ordering for. ## Customers A customer is the B2C user entity, identified by `customerId`. Customers are standalone accounts that do not belong to a company. Each customer carries personal details and their own account data: | Field | Description | |---|---| | `firstName`, `middleName`, `lastName` | Full name | | `email` | Email address | | `phone`, `mobile` | Phone numbers | | `gender` | M, F or U (unknown) | | `dateOfBirth` | Date of birth | | `primaryLanguage` | Preferred language | | `login` | The email used for authentication. When null, no account has been linked yet | | `debtorId` | Financial identifier for invoicing | | `expires` | Optional expiration date. After this date the account is disabled | | `mailingList` | Whether the customer is subscribed to mailings (Y/N) | Customers have their own addresses (delivery and invoice) and their own order history. ## Addresses Companies and customers have addresses. Contacts do not have their own addresses. They use the addresses of the company they are ordering for. There are two address types: `delivery` and `invoice`. Each address includes: | Field | Description | |---|---| | `street`, `number`, `numberExtension` | Street address | | `postalCode`, `city`, `region`, `country` | Location | | `firstName`, `middleName`, `lastName`, `gender` | Addressee | | `company` | Company name on the address | | `phone`, `mobile`, `email` | Contact details | | `isDefault` | Whether this is the default address for its type (Y/N) | | `type` | delivery or invoice | | `active` | Whether the address is active (Y/N) | | `icp` | Intra-Community Performance flag (Y/N). Used for B2B cross-border orders to determine whether tax should be applied | | `code`, `notes`, `name` | Custom code, delivery notes and a friendly descriptive name | A company can have multiple addresses per type. For example, a company might have two delivery addresses at different locations, with one marked as default. ## Purchase authorization Purchase authorization controls what contacts are allowed to order within a company. Each contact-company relationship has a `PurchaseAuthorizationConfig` that defines a role and an optional spending limit. ### Roles | Role | Description | |---|---| | `PURCHASER` | Default role. Can place orders but is subject to an authorization limit per order. When the order total exceeds the limit, the order cannot be completed without approval from an authorization manager | | `AUTHORIZATION_MANAGER` | Manages authorization limits and purchase requests for purchasers. Has no spending limit | ### Authorization limit The `authorizationLimit` field sets the maximum amount a purchaser can spend per order. When null (for authorization managers), there is no limit. A contact has a separate configuration per company they belong to. This means the same person can have different roles and limits at different companies. For example, within a single company: ``` Brouwer Industrie NL ├── Lisa de Vries — AUTHORIZATION_MANAGER (no limit) ├── Tom Hendriks — PURCHASER (limit: €0, all orders require approval) ├── Eva Mulder — PURCHASER (limit: €250) └── Jan Brouwer — no config (no limit) ``` When Eva places an order above €250, it requires authorization from Lisa before it can be processed. Contacts without a purchase authorization config, like Jan, can order without restrictions. ## How this relates to orderlists Orderlists are customer-specific catalogs that control which products and clusters are visible and orderable for specific accounts. An orderlist can be assigned to companies or individual users (contacts or customers). Each orderlist has a code, descriptions, optional validity dates (`validFrom`, `validTo`) and an active flag. When an orderlist is assigned, only the products and clusters in that list are available to the account. ## How this relates to pricesheets Pricesheets provide customer-specific pricing. A pricesheet can be assigned to companies, contacts or customers. Each pricesheet has a code, localized names and descriptions, a priority (to determine which applies when multiple match) and a readonly flag. For example, a company might have a "Premium customers" pricesheet assigned at the company level, giving all contacts within that company access to special pricing. For the full pricing model, see [Understanding pricing layers](/frontend/domain-guides/pricing-and-discounts/understanding-pricing-layers). ## How this relates to favorite lists Favorite lists let users save collections of products and clusters for quick reordering. A favorite list can belong to a company (`companyId`), a contact (`contactId`) or a customer (`customerId`). Company-level lists are shared across all contacts in that company. Contact-level and customer-level lists are personal. For example, a company might have a shared list for commonly ordered supplies, while individual contacts maintain their own lists for specific projects. For practical details, see [Favorite lists](/frontend/domain-guides/accounts-and-authentication/favorite-lists). ## How this relates to attributes Companies, contacts and customers can all have attributes. The attribute system works the same way as for products: each attribute is defined by an `attributeDescription` with a name, type, group and behavior flags. Company attributes might store business-specific data like payment possibilities or business type classifications. Contact attributes might control ordering behavior, such as whether a contact can only order via quotes or has a budget restriction. System attributes (where `isSystem` is true and `isHidden` is true) are used internally for features like user group assignments and are not meant to be displayed to end users. --- ## Cart Management URL: https://docs.propeller-commerce.com/frontend/domain-guides/cart-and-checkout/cart-management # Cart management Create and manage shopping carts using Propeller's GraphQL API. Add items, update quantities, apply discount codes, fetch totals and handle cart persistence across sessions. Propeller calculates all pricing, discounts and taxes server-side. ## Creating a cart Use `cartStart` to create a new cart. The mutation returns the full `Cart` object including a `cartId` (UUID) that you need for all subsequent operations. **For a B2B contact:** ```graphql mutation { cartStart( input: { contactId: 312 companyId: 456 } ) { cartId contactId companyId createdAt } } ``` Expected response: ```json { "data": { "cartStart": { "cartId": "019abc12-3456-7def-8901-234567890abc", "contactId": 312, "companyId": 456, "createdAt": "2026-02-27T10:15:00.000Z" } } } ``` **For a B2C customer:** ```graphql mutation { cartStart( input: { customerId: 789 } ) { cartId customerId createdAt } } ``` **For a guest user:** ```graphql mutation { cartStart { cartId createdAt } } ``` Omit all IDs to create a guest cart. You can associate it with a user later using `cartSetContact` or `cartSetCustomer` (see [Changing cart ownership](#changing-cart-ownership)). Store the `cartId` in your application state or `localStorage`. Every cart operation requires it. ## Adding items Use `cartAddItem` to add a product to the cart: ```graphql mutation { cartAddItem( id: "019abc12-3456-7def-8901-234567890abc" input: { productId: 104708 quantity: 3 } ) { items { itemId productId quantity price priceNet totalPrice totalPriceNet } total { subTotal subTotalNet totalGross totalNet } } } ``` Expected response: ```json { "data": { "cartAddItem": { "items": [ { "itemId": "019abc13-1a2b-7c3d-4e5f-678901234567", "productId": 104708, "quantity": 3, "price": 24.50, "priceNet": 29.65, "totalPrice": 73.50, "totalPriceNet": 88.94 } ], "total": { "subTotal": 73.50, "subTotalNet": 88.94, "totalGross": 73.50, "totalNet": 88.94 } } } } ``` The `input` accepts these fields: | Field | Type | Description | |---|---|---| | `productId` | `Int!` | Product to add (required) | | `quantity` | `Int` | Number of units. Defaults to `1` | | `clusterId` | `Int` | Cluster the product belongs to, if applicable | | `notes` | `String` | Item-level notes (e.g., special instructions) | | `price` | `Float` | Custom unit price. Overrides platform pricing when set | | `childItems` | `[CartChildItemInput!]` | Child items for configurable clusters | The response includes an `itemId` for each cart item. You need this ID for updating or deleting the item later. If the product has `minimumQuantity` or `purchaseUnit` constraints, the API rejects quantities that violate these rules. ### Adding items with child items For configurable clusters where a main product has selectable options (e.g., a workstation with monitors and peripherals), pass `childItems`: ```graphql mutation { cartAddItem( id: "019abc12-3456-7def-8901-234567890abc" input: { productId: 104900 clusterId: 2150 quantity: 1 childItems: [ { productId: 104901, quantity: 1 } { productId: 104902, quantity: 2 } ] } ) { items { itemId productId quantity price priceNet childItems { itemId productId quantity price priceNet } } } } ``` Expected response: ```json { "data": { "cartAddItem": { "items": [ { "itemId": "019abc15-3c4d-7e5f-6071-890123456789", "productId": 104900, "quantity": 1, "price": 500, "priceNet": 605, "childItems": [ { "itemId": "019abc15-3c4d-7e5f-6071-890123456790", "productId": 104901, "quantity": 1, "price": 0, "priceNet": 0 }, { "itemId": "019abc15-3c4d-7e5f-6071-890123456791", "productId": 104902, "quantity": 2, "price": 0, "priceNet": 0 } ] } ] } } } ``` Each child item accepts `productId` (required), `quantity` (defaults to 1), `notes` and `price`. ### Adding bundles Use `cartAddBundle` to add a predefined product bundle: ```graphql mutation { cartAddBundle( id: "019abc12-3456-7def-8901-234567890abc" input: { bundleId: "019c9425-5eaf-72b5-9315-d7182932db45" quantity: 1 } ) { items { itemId productId bundleId quantity price priceNet } } } ``` Expected response: ```json { "data": { "cartAddBundle": { "items": [ { "itemId": "019ca0f6-faba-7e17-88fd-afb653794297", "productId": 2017, "bundleId": "019c9425-5eaf-72b5-9315-d7182932db45", "quantity": 1, "price": 270.885, "priceNet": 327.77 } ] } } } ``` The `input` accepts `bundleId` (required), `quantity` (defaults to 1) and `notes`. ### Bulk item operations Use `cartItemBulk` to add or update multiple items in a single call: ```graphql mutation { cartItemBulk( input: { cartId: "019abc12-3456-7def-8901-234567890abc" items: [ { productId: 104708, quantity: 5 } { productId: 104923, quantity: 2 } { itemId: "019abc13-1a2b-7c3d-4e5f-678901234567", quantity: 10 } ] } ) { created updated total } } ``` Expected response: ```json { "data": { "cartItemBulk": { "created": 2, "updated": 1, "total": 3 } } } ``` Unlike other cart mutations, the `cartId` is inside the input object. Each item can use `productId` to create a new line or `itemId` to update an existing one. `cartItemBulk` is particularly useful for B2B workflows like reordering, where a buyer adds dozens of items at once from a previous order or an imported product list. ## Updating items Use `cartUpdateItem` to change the quantity, notes or price of an existing item. Note the three separate arguments: `id` (cart), `itemId` (item) and `input`: ```graphql mutation { cartUpdateItem( id: "019abc12-3456-7def-8901-234567890abc" itemId: "019abc13-1a2b-7c3d-4e5f-678901234567" input: { quantity: 5 notes: "Deliver to warehouse B" } ) { items { itemId productId quantity notes price priceNet totalPrice totalPriceNet } total { totalGross totalNet } } } ``` Expected response: ```json { "data": { "cartUpdateItem": { "items": [ { "itemId": "019abc13-1a2b-7c3d-4e5f-678901234567", "productId": 104708, "quantity": 5, "notes": "Deliver to warehouse B", "price": 24.50, "priceNet": 29.65, "totalPrice": 122.50, "totalPriceNet": 148.23 } ], "total": { "totalGross": 122.50, "totalNet": 148.23 } } } } ``` The `input` accepts `quantity` (defaults to 1), `notes` and `price` (custom unit price override). ## Removing items Use `cartDeleteItem` to remove an item from the cart: ```graphql mutation { cartDeleteItem( id: "019abc12-3456-7def-8901-234567890abc" input: { itemId: "019abc13-1a2b-7c3d-4e5f-678901234567" } ) { items { itemId productId quantity } total { totalGross totalNet } } } ``` Expected response: ```json { "data": { "cartDeleteItem": { "items": [], "total": { "totalGross": 0, "totalNet": 0 } } } } ``` ## Fetching cart state Use the `cart` query to retrieve the full cart at any point: ```graphql query { cart(id: "019abc12-3456-7def-8901-234567890abc") { cartId contactId companyId customerId notes reference actionCode status createdAt lastModifiedAt total { subTotal subTotalNet totalGross totalNet discount discountNet discountPercentage } taxLevels { taxPercentage price } items { itemId productId clusterId quantity notes price priceNet totalPrice totalPriceNet sum sumNet totalSum totalSumNet discount discountPercentage taxCode childItems { itemId productId quantity price priceNet } } bonusItems { itemId productId quantity totalPrice totalPriceNet } } } ``` Expected response: ```json { "data": { "cart": { "cartId": "019abc12-3456-7def-8901-234567890abc", "contactId": 312, "companyId": 456, "customerId": null, "notes": "", "reference": "PO-2026-0042", "actionCode": "", "status": "OPEN", "createdAt": "2026-02-27T10:15:00.000Z", "lastModifiedAt": "2026-02-27T11:30:00.000Z", "total": { "subTotal": 196.00, "subTotalNet": 237.16, "totalGross": 196.00, "totalNet": 237.16, "discount": 0, "discountNet": 0, "discountPercentage": 0 }, "taxLevels": [ { "taxPercentage": 21, "price": 41.16 } ], "items": [ { "itemId": "019abc13-1a2b-7c3d-4e5f-678901234567", "productId": 104708, "clusterId": null, "quantity": 5, "notes": "Deliver to warehouse B", "price": 24.50, "priceNet": 29.65, "totalPrice": 122.50, "totalPriceNet": 148.23, "sum": 24.50, "sumNet": 29.65, "totalSum": 122.50, "totalSumNet": 148.23, "discount": 0, "discountPercentage": 0, "taxCode": "H", "childItems": [] }, { "itemId": "019abc14-2b3c-7d4e-5f60-789012345678", "productId": 104923, "clusterId": null, "quantity": 2, "notes": "", "price": 36.75, "priceNet": 44.47, "totalPrice": 73.50, "totalPriceNet": 88.94, "sum": 36.75, "sumNet": 44.47, "totalSum": 73.50, "totalSumNet": 88.94, "discount": 0, "discountPercentage": 0, "taxCode": "H", "childItems": [] } ], "bonusItems": [] } } } ``` The cart also has fields for `invoiceAddress`, `deliveryAddress`, `paymentData`, `postageData`, `payMethods`, `carriers` and `shippingMethods`. These are used during checkout and documented in [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow). ### Understanding totals Propeller calculates all totals server-side. You do not need to compute anything in your frontend. | Field | Description | |---|---| | `subTotal` | Sum of all items, excluding shipping, payment costs and discounts. Excluding VAT | | `subTotalNet` | Same as above, including VAT | | `totalGross` | Final payable amount, excluding VAT | | `totalNet` | Final payable amount, including VAT | | `discount` | Discount applied through incentives, excluding VAT | | `discountNet` | Discount applied through incentives, including VAT | | `discountPercentage` | Discount percentage applied through incentives | The `taxLevels` array breaks down tax amounts per tax rate. This is useful when multiple VAT rates apply (e.g., standard 21% and reduced 9%). > **Naming convention:** Throughout the cart API, "Net" means **including VAT** and "Gross" means **excluding VAT**. The `price` field on items is always excluding VAT and `priceNet` is including VAT. This convention is consistent across the entire API. ### Understanding item prices Each cart item has several price fields: | Field | Description | |---|---| | `price` / `priceNet` | Unit price excluding / including VAT | | `totalPrice` / `totalPriceNet` | Total for this item after item-specific discounts | | `sum` / `sumNet` | Unit price including child item prices, before item discounts | | `totalSum` / `totalSumNet` | Final total including child items, quantity and discounts | For items without child items, `price` equals `sum` and `totalPrice` equals `totalSum`. The `sum` fields become relevant when you use configurable clusters with child items. ## Applying discount codes Use `cartAddActionCode` to apply a discount code (called an "action code" in Propeller): ```graphql mutation { cartAddActionCode( id: "019abc12-3456-7def-8901-234567890abc" input: { actionCode: "EXTRASALE10" } ) { actionCode total { subTotal subTotalNet totalGross totalNet discount discountNet discountPercentage } } } ``` Expected response: ```json { "data": { "cartAddActionCode": { "actionCode": "EXTRASALE10", "total": { "subTotal": 270.885, "subTotalNet": 327.77, "totalGross": 315.885, "totalNet": 382.22, "discount": 10, "discountNet": 12.10, "discountPercentage": 3.69 } } } } ``` The `actionCode` field on the cart is only filled when the code is valid. If the code does not exist or has expired, the mutation returns an error. Remove a discount code with `cartRemoveActionCode`: ```graphql mutation { cartRemoveActionCode( id: "019abc12-3456-7def-8901-234567890abc" input: { actionCode: "EXTRASALE10" } ) { actionCode total { totalGross totalNet discount discountPercentage } } } ``` Expected response: ```json { "data": { "cartRemoveActionCode": { "actionCode": "", "total": { "totalGross": 325.885, "totalNet": 394.32, "discount": 0, "discountPercentage": 0 } } } } ``` Both mutations use the same input type (`CartActionCodeInput`). ## Updating cart details Use `cartUpdate` to set notes, a reference number or custom fields on the cart: ```graphql mutation { cartUpdate( id: "019abc12-3456-7def-8901-234567890abc" input: { notes: "Please deliver before 14:00" reference: "PO-2026-0042" } ) { notes reference } } ``` Expected response: ```json { "data": { "cartUpdate": { "notes": "Please deliver before 14:00", "reference": "PO-2026-0042" } } } ``` The `input` also accepts `extra3` and `extra4` for additional custom data. All of these fields persist to the order after checkout. The `reference` field is typically used by B2B buyers to store their internal purchase order (PO) number. Companies require PO numbers on orders so they can match invoices to approved purchase requests in their financial system. For B2C, this field is rarely used. The `cartUpdate` mutation is also used for setting shipping and payment data during checkout. See [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow) for those operations. ## Changing cart ownership To associate a guest cart with a user after login, use `cartSetContact` (B2B) or `cartSetCustomer` (B2C): ```graphql mutation { cartSetContact( id: "019abc12-3456-7def-8901-234567890abc" input: { contactId: 312 companyId: 456 } ) { cartId contactId companyId } } ``` ```graphql mutation { cartSetCustomer( id: "019abc12-3456-7def-8901-234567890abc" input: { customerId: 789 } ) { cartId customerId } } ``` For `cartSetContact`, the `companyId` is optional and defaults to the contact's parent company. ## Deleting a cart Use `cartDelete` to permanently remove a cart: ```graphql mutation { cartDelete(id: "019abc12-3456-7def-8901-234567890abc") } ``` Expected response: ```json { "data": { "cartDelete": true } } ``` Returns `true` on success. ## Cart persistence ### Guest users For unauthenticated users, store the `cartId` locally after calling `cartStart`. After calling `cartStart`, store the returned `cartId` in browser storage (for example `localStorage`). This is the only value you need to persist on the client side. On page load, retrieve the stored `cartId` and use it with the `cart` query to restore the cart state. After order completion, clear the stored `cartId`. Cart items persist server-side in Propeller. Only the `cartId` reference needs to be stored locally. Checkout data (addresses, payment, shipping) is retained for 30 days. ### Authenticated users For logged-in users, retrieve all their carts with the `carts` query: ```graphql query { carts( input: { contactIds: [312] companyIds: [456] } ) { items { cartId createdAt lastModifiedAt items { itemId productId quantity } total { totalGross totalNet } } itemsFound page pages } } ``` Expected response: ```json { "data": { "carts": { "items": [ { "cartId": "019abc12-3456-7def-8901-234567890abc", "createdAt": "2026-02-27T10:15:00.000Z", "lastModifiedAt": "2026-02-27T11:30:00.000Z", "items": [ { "itemId": "019abc13-1a2b-7c3d-4e5f-678901234567", "productId": 104708, "quantity": 5 } ], "total": { "totalGross": 122.50, "totalNet": 148.23 } } ], "itemsFound": 1, "page": 1, "pages": 1 } } } ``` The `CartSearchInput` supports these filters: | Field | Type | Description | |---|---|---| | `ids` | `[String!]` | Filter by specific cart IDs | | `contactIds` | `[Int!]` | Filter by contact IDs | | `customerIds` | `[Int!]` | Filter by customer IDs | | `companyIds` | `[Int!]` | Filter by company IDs | | `statuses` | `[CartStatus!]` | Filter by cart status (`OPEN` or `PENDING_PURCHASE_AUTHORIZATION`) | | `createdAt` | `DateSearchInput` | Filter by creation date range | | `lastModifiedAt` | `DateSearchInput` | Filter by last modification date range | | `page` | `Int` | Page number (defaults to 1) | | `offset` | `Int` | Items per page (defaults to 12) | | `sortInputs` | `[CartSortInput!]` | Sort by `ID`, `LAST_MODIFIED_AT` or `CREATED_AT` | ### Guest-to-authenticated transition When a guest user logs in, you may need to merge their guest cart with an existing authenticated cart: 1. Retrieve the guest `cartId` from `localStorage` 2. Query the authenticated user's carts with the `carts` query 3. If the user has an existing cart, merge items using `cartAddItem` on the authenticated cart. Alternatively, associate the guest cart with the user using `cartSetContact` or `cartSetCustomer` 4. Update `localStorage` with the active `cartId` ## Multiple carts Propeller supports multiple active carts per user. Each cart has its own `cartId` and operates independently. In B2B, buyers often manage separate carts for different departments, cost centers or projects within their organization. Each cart maps to a different budget line or internal approval path. Common use cases include: - **Project carts** for different departments or purchase orders - **Saved carts** that users want to complete later - **Quick-order carts** for recurring purchases Create additional carts with `cartStart` and switch between them by using the appropriate `cartId` in your queries and mutations. Use the `carts` query to list all carts for a user. ## Error handling Cart mutations return errors in the GraphQL response `errors` array. Common errors include: | Error | Cause | |---|---| | Cart not found | Invalid or expired `cartId` | | Product not found | Invalid `productId` | | Cart item not found | Invalid `itemId` | | Pricing information not found | Product has no price configured | | Invalid action code | Discount code does not exist or has expired | ## Next steps - [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow) for setting addresses, shipping, payment and creating the order - [Customer-specific pricing](/frontend/domain-guides/pricing-and-discounts/customer-specific-pricing) for how price sheets affect cart totals - [Add a product to cart](/frontend/recipes/add-product-to-cart) for a ready-to-use recipe - [Fetch cart with totals](/frontend/recipes/fetch-cart-with-totals) for a ready-to-use recipe --- ## Checkout Flow URL: https://docs.propeller-commerce.com/frontend/domain-guides/cart-and-checkout/checkout-flow # Checkout flow Set addresses, shipping and payment on a cart, review the order and convert it into a Propeller order using `cartProcess`. This page picks up where [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) leaves off (you already have a cart with items) and ends when the order is created. For payment processing after order creation, see [Payment integration](/frontend/domain-guides/cart-and-checkout/payment-integration). ## Guest, B2C and B2B checkout The checkout flow works for three types of users. The main difference is where address data comes from. | Scenario | User type | Address source | Prefill possible | |---|---|---|---| | Guest | None (anonymous cart) | Manual entry | No | | B2C | `Customer` | `customer.addresses` | Yes | | B2B | `Contact` | `contact.company.addresses` | Yes | For guest checkout, the user must enter all address fields manually. For logged-in users (B2C customers or B2B contacts), you can prefill addresses from their account data. ## Prefilling addresses for logged-in users Use the `viewer` query to fetch the current user's details and stored addresses. The query returns either a `Contact` (B2B) or a `Customer` (B2C), determined by `__typename`. ### Query ```graphql query { viewer { __typename firstName middleName lastName email phone mobile ... on Contact { contactId company { companyId name addresses { id firstName middleName lastName company street number numberExtension postalCode city country email phone type isDefault icp notes } } } ... on Customer { customerId addresses { id firstName middleName lastName street number numberExtension postalCode city country email phone type isDefault icp notes } } } } ``` ### Response ```json { "data": { "viewer": { "__typename": "Contact", "firstName": "Sophie", "middleName": "van", "lastName": "Dijk", "email": "sophie@brouwerindustrie.nl", "phone": "+31201234567", "mobile": "+31612345678", "contactId": 42, "company": { "companyId": 10, "name": "Brouwer Industrie", "addresses": [ { "id": 101, "firstName": "Sophie", "middleName": "van", "lastName": "Dijk", "company": "Brouwer Industrie", "street": "Keizersgracht", "number": "452", "numberExtension": null, "postalCode": "1016 GE", "city": "Amsterdam", "country": "NL", "email": "sophie@brouwerindustrie.nl", "phone": "+31201234567", "type": "delivery", "isDefault": "Y", "icp": "N", "notes": null }, { "id": 102, "firstName": "Sophie", "middleName": "van", "lastName": "Dijk", "company": "Brouwer Industrie", "street": "Herengracht", "number": "100", "numberExtension": "A", "postalCode": "1015 BS", "city": "Amsterdam", "country": "NL", "email": "finance@brouwerindustrie.nl", "phone": "+31201234567", "type": "invoice", "isDefault": "Y", "icp": "N", "notes": null } ] } } } } ``` ### How it works For a `Contact`, addresses come from the company (`contact.company.addresses`). For a `Customer`, addresses are on the customer directly (`customer.addresses`). Use the `type` field (`delivery` or `invoice`) and the `isDefault` field to pre-select the appropriate address for each step of checkout. A company can have multiple addresses of the same type. For example, a company with two warehouses might have two delivery addresses. Present all addresses to the user and pre-select the one where `isDefault` is `Y`. > The `type` field on user addresses uses lowercase values (`delivery`, `invoice`), while the `CartAddressType` enum used in `cartUpdateAddress` uses uppercase values (`DELIVERY`, `INVOICE`). Map between these when prefilling. ## Setting addresses Use `cartUpdateAddress` to set the delivery and invoice addresses on the cart. Call this mutation twice: once for each address type. ### Set the delivery address #### Mutation ```graphql mutation SetDeliveryAddress($cartId: String!) { cartUpdateAddress( id: $cartId input: { type: DELIVERY company: "Brouwer Industrie" firstName: "Sophie" middleName: "van" lastName: "Dijk" street: "Keizersgracht" number: "452" postalCode: "1016 GE" city: "Amsterdam" country: "NL" email: "sophie@brouwerindustrie.nl" phone: "+31201234567" } ) { deliveryAddress { firstName middleName lastName company street number numberExtension postalCode city country email phone } } } ``` #### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` #### Response ```json { "data": { "cartUpdateAddress": { "deliveryAddress": { "firstName": "Sophie", "middleName": "van", "lastName": "Dijk", "company": "Brouwer Industrie", "street": "Keizersgracht", "number": "452", "numberExtension": null, "postalCode": "1016 GE", "city": "Amsterdam", "country": "NL", "email": "sophie@brouwerindustrie.nl", "phone": "+31201234567" } } } } ``` ### Set the invoice address #### Mutation ```graphql mutation SetInvoiceAddress($cartId: String!) { cartUpdateAddress( id: $cartId input: { type: INVOICE company: "Brouwer Industrie" firstName: "Sophie" middleName: "van" lastName: "Dijk" street: "Herengracht" number: "100" numberExtension: "A" postalCode: "1015 BS" city: "Amsterdam" country: "NL" email: "finance@brouwerindustrie.nl" phone: "+31201234567" } ) { invoiceAddress { firstName middleName lastName company street number numberExtension postalCode city country email phone } } } ``` #### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` #### Response ```json { "data": { "cartUpdateAddress": { "invoiceAddress": { "firstName": "Sophie", "middleName": "van", "lastName": "Dijk", "company": "Brouwer Industrie", "street": "Herengracht", "number": "100", "numberExtension": "A", "postalCode": "1015 BS", "city": "Amsterdam", "country": "NL", "email": "finance@brouwerindustrie.nl", "phone": "+31201234567" } } } } ``` ### How it works The `type` field determines which address you are setting: `DELIVERY` for shipping or `INVOICE` for billing. Both mutations return the full cart, so you can request either or both address objects in the response to confirm the update. Required fields are `type`, `firstName`, `lastName`, `street`, `postalCode` and `city`. The `country` field defaults to `"NL"` if not provided. The `icp` field (Intra-Community Performance) defaults to `N` and controls whether tax is applied for B2B cross-border orders within the EU. Propeller recalculates totals after each address update, because the delivery address can affect shipping costs and the tax calculation. ## Choosing a shipping method Shipping configuration has three parts: selecting a shipping method, optionally selecting a carrier or pickup location, and updating the cart. ### Fetch available shipping methods #### Query ```graphql query GetShippingMethods($cartId: String!) { cart(id: $cartId) { shippingMethods { name code } } } ``` #### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` #### Response ```json { "data": { "cart": { "shippingMethods": [ { "name": "Delivery", "code": "DELIVERY" }, { "name": "Pickup", "code": "PICKUP" } ] } } } ``` Present these options to the customer. If they choose delivery, fetch available carriers. If they choose pickup, fetch available pickup locations. ### Fetch available carriers #### Query ```graphql query GetCarriers($cartId: String!) { cart(id: $cartId) { carriers { id name price logo deliveryDeadline } } } ``` #### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` #### Response ```json { "data": { "cart": { "carriers": [ { "id": 1, "name": "PostNL", "price": 6.95, "logo": null, "deliveryDeadline": "2026-03-03T17:00:00Z" }, { "id": 2, "name": "DHL", "price": 8.95, "logo": null, "deliveryDeadline": "2026-03-02T12:00:00Z" } ] } } } ``` The `price` on a carrier is an estimate. The final shipping cost is calculated by Propeller's business rules when you set the carrier on the cart. ### Fetch pickup locations If the customer chooses self-pickup, fetch active pickup locations using the `warehouses` query. #### Query ```graphql query GetPickupLocations { warehouses(input: { isActive: true, isPickupLocation: true }) { items { id name description address { street number city postalCode country phone } businessHours { dayOfWeek openingTime closingTime } } itemsFound } } ``` #### Response ```json { "data": { "warehouses": { "items": [ { "id": 1, "name": "Magazijn Amsterdam", "description": "Afhaallocatie Amsterdam", "address": { "street": "Industrieweg", "number": "12", "city": "Amsterdam", "postalCode": "1099 DC", "country": "NL", "phone": "+31201234000" }, "businessHours": [ { "dayOfWeek": 1, "openingTime": "08:00", "closingTime": "17:00" }, { "dayOfWeek": 2, "openingTime": "08:00", "closingTime": "17:00" }, { "dayOfWeek": 3, "openingTime": "08:00", "closingTime": "17:00" }, { "dayOfWeek": 4, "openingTime": "08:00", "closingTime": "17:00" }, { "dayOfWeek": 5, "openingTime": "08:00", "closingTime": "15:00" } ] } ], "itemsFound": 1 } } } ``` ### Set shipping method for delivery After the customer selects a shipping method and carrier, update the cart using `cartUpdate` with `postageData`. #### Mutation ```graphql mutation SetShippingMethod($cartId: String!) { cartUpdate( id: $cartId input: { postageData: { method: "DELIVERY" carrier: "postnl" partialDeliveryAllowed: Y } } ) { postageData { method carrier price priceNet taxPercentage partialDeliveryAllowed requestDate priceMode } total { subTotal subTotalNet totalGross totalNet discount discountNet } taxLevels { taxPercentage price } } } ``` #### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` #### Response ```json { "data": { "cartUpdate": { "postageData": { "method": "DELIVERY", "carrier": "postnl", "price": 6.95, "priceNet": 8.41, "taxPercentage": 21, "partialDeliveryAllowed": "Y", "requestDate": null, "priceMode": "PLATFORM" }, "total": { "subTotal": 4785.12, "subTotalNet": 5789.99, "totalGross": 4792.07, "totalNet": 5798.40, "discount": 0, "discountNet": 0 }, "taxLevels": [ { "taxPercentage": 21, "price": 1006.33 } ] } } } ``` The `priceMode` field indicates whether the shipping cost was calculated by Propeller's business rules (`PLATFORM`) or set externally (`EXTERNAL`). To override shipping costs, pass a `price` in the `postageData` input, which sets `priceMode` to `EXTERNAL`. The `partialDeliveryAllowed` flag lets the buyer indicate whether the supplier can ship available stock immediately and send remaining items in a follow-up shipment. B2B buyers often prefer partial deliveries because delaying the entire order until all items are available can halt production or operations. ### Set shipping method for self-pickup #### Mutation ```graphql mutation SetPickupMethod($cartId: String!) { cartUpdate( id: $cartId input: { postageData: { method: "PICKUP" pickUpLocationId: 1 } } ) { postageData { method price priceNet pickUpLocationId } total { totalGross totalNet } } } ``` #### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` #### Response ```json { "data": { "cartUpdate": { "postageData": { "method": "PICKUP", "price": 0, "priceNet": 0, "pickUpLocationId": 1 }, "total": { "totalGross": 4785.12, "totalNet": 5789.99 } } } } ``` The `pickUpLocationId` corresponds to a warehouse `id` from the `warehouses` query. ### Requesting a specific delivery date You can pass a preferred delivery date using the `requestDate` field in `postageData`: ```graphql postageData: { method: "DELIVERY" carrier: "postnl" requestDate: "2026-03-10T00:00:00Z" } ``` This stores the customer's requested date on the cart. Whether this date is honored depends on carrier availability and Propeller's business rules. ## Choosing a payment method ### Fetch available payment methods #### Query ```graphql query GetPaymentMethods($cartId: String!) { cart(id: $cartId) { payMethods { code name type price taxCode } } } ``` #### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` #### Response ```json { "data": { "cart": { "payMethods": [ { "code": "ACCOUNT", "name": "Pay on account", "type": "ACCOUNT", "price": 0, "taxCode": "N" }, { "code": "IDEAL", "name": "iDEAL", "type": "IDEAL", "price": 0.29, "taxCode": "H" }, { "code": "CREDITCARD", "name": "Credit Card", "type": "CREDITCARD", "price": 0.50, "taxCode": "H" } ] } } } ``` The `price` on a payment method is the transaction fee. The final fee is calculated by Propeller's business rules when you set the method on the cart. ### Set the payment method #### Mutation ```graphql mutation SetPaymentMethod($cartId: String!) { cartUpdate( id: $cartId input: { paymentData: { method: "IDEAL" status: "OPEN" } } ) { paymentData { method price priceNet taxPercentage status statusDate priceMode } total { subTotal subTotalNet totalGross totalNet discount discountNet } taxLevels { taxPercentage price } } } ``` #### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` #### Response ```json { "data": { "cartUpdate": { "paymentData": { "method": "IDEAL", "price": 0.29, "priceNet": 0.35, "taxPercentage": 21, "status": "OPEN", "statusDate": null, "priceMode": "PLATFORM" }, "total": { "subTotal": 4785.12, "subTotalNet": 5789.99, "totalGross": 4792.36, "totalNet": 5798.75, "discount": 0, "discountNet": 0 }, "taxLevels": [ { "taxPercentage": 21, "price": 1006.39 } ] } } } ``` The default payment method is `ACCOUNT` (pay on account). If the customer selects a different method, the totals are recalculated to include the transaction fee. Like shipping costs, you can override the transaction fee by passing a `price` in the `paymentData` input. Pay on account is the most common payment method in B2B commerce. Instead of paying at checkout, the company receives an invoice with payment terms (typically 30, 60 or 90 days). When the customer selects pay on account, no PSP redirect is needed after order creation. See [Payment integration](/frontend/domain-guides/cart-and-checkout/payment-integration#pay-on-account-flow) for the simplified flow. ## Reviewing the cart Before creating the order, fetch the complete cart for a final review. This gives the customer a chance to verify items, addresses, shipping, payment and totals. ### Query ```graphql query ReviewCart($cartId: String!) { cart(id: $cartId) { items { itemId productId quantity price priceNet totalPrice totalPriceNet } deliveryAddress { firstName middleName lastName company street number numberExtension postalCode city country } invoiceAddress { firstName middleName lastName company street number numberExtension postalCode city country } postageData { method carrier price priceNet taxPercentage } paymentData { method price priceNet taxPercentage } total { subTotal subTotalNet discount discountNet discountPercentage totalGross totalNet } taxLevels { taxPercentage price } actionCode } } ``` ### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` ### Response ```json { "data": { "cart": { "items": [ { "itemId": "019c77b7-72a0-75fe-8a20-b98927d76652", "productId": 25, "quantity": 3, "price": 1595.04, "priceNet": 1929.9984, "totalPrice": 4785.12, "totalPriceNet": 5789.99 } ], "deliveryAddress": { "firstName": "Sophie", "middleName": "van", "lastName": "Dijk", "company": "Brouwer Industrie", "street": "Keizersgracht", "number": "452", "numberExtension": null, "postalCode": "1016 GE", "city": "Amsterdam", "country": "NL" }, "invoiceAddress": { "firstName": "Sophie", "middleName": "van", "lastName": "Dijk", "company": "Brouwer Industrie", "street": "Herengracht", "number": "100", "numberExtension": "A", "postalCode": "1015 BS", "city": "Amsterdam", "country": "NL" }, "postageData": { "method": "DELIVERY", "carrier": "postnl", "price": 6.95, "priceNet": 8.41, "taxPercentage": 21 }, "paymentData": { "method": "IDEAL", "price": 0.29, "priceNet": 0.35, "taxPercentage": 21 }, "total": { "subTotal": 4785.12, "subTotalNet": 5789.99, "discount": 0, "discountNet": 0, "discountPercentage": 0, "totalGross": 4792.36, "totalNet": 5798.75 }, "taxLevels": [ { "taxPercentage": 21, "price": 1006.39 } ], "actionCode": "" } } } ``` Display this information clearly so the customer can verify everything before confirming. The pricing convention used throughout the cart: `price` fields are gross (excluding VAT) and `priceNet` fields are net (including VAT). ## Creating the order Convert the cart to an order using `cartProcess`. ### Mutation ```graphql mutation CreateOrder($cartId: String!) { cartProcess( id: $cartId input: { orderStatus: "UNFINISHED" } ) { cartOrderId order { id uuid status type } } } ``` ### Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` ### Response ```json { "data": { "cartProcess": { "cartOrderId": 534, "order": { "id": 534, "uuid": "019c8a12-3456-7890-abcd-ef1234567890", "status": "UNFINISHED", "type": "dropshipment" } } } } ``` The `cartOrderId` is the Propeller order ID. You need this for payment tracking and order confirmation. The `order` field returns the full created order object if you need additional fields. ### Choosing an order status | Status | When to use | |---|---| | `UNFINISHED` | Customer needs to complete online payment (iDEAL, credit card, etc.). Create the order first, then redirect to the PSP | | `NEW` | No online payment required (e.g. pay on account). The order goes directly to a confirmed state | For orders requiring online payment, always use `UNFINISHED` first. After payment succeeds, update the status with `orderSetStatus`. See [Payment integration](/frontend/domain-guides/cart-and-checkout/payment-integration) for the full payment flow. ### Order confirmation language The `language` field on `CartProcessInput` controls which language the order confirmation email is sent in: ```graphql cartProcess( id: $cartId input: { orderStatus: "UNFINISHED", language: "NL" } ) ``` If not provided, the default language is used. ## Purchase authorization Purchase authorization is a standard procurement control in B2B. Companies configure spending limits per contact to prevent unauthorized purchases. When a contact places an order that exceeds their limit, the cart enters a pending state until an authorization manager (typically a procurement lead or department head) approves or rejects it. Your frontend should show a clear message explaining that the order is awaiting approval. The cart has a `purchaseAuthorizationRequired` field. When this is `true`, the contact's spending limit has been exceeded and the order requires approval before it can be processed. Check this field before calling `cartProcess`: ```graphql query CheckAuthorization($cartId: String!) { cart(id: $cartId) { purchaseAuthorizationRequired status } } ``` Expected response: ```json { "data": { "cart": { "purchaseAuthorizationRequired": false, "status": "OPEN" } } } ``` If `purchaseAuthorizationRequired` is `true`, call `cartRequestPurchaseAuthorization` instead of `cartProcess`. The cart status changes to `PENDING_PURCHASE_AUTHORIZATION` until an authorization manager approves it with `cartAcceptPurchaseAuthorizationRequest`. For more on how purchase authorization roles and limits work, see [Understanding companies, contacts and customers](/frontend/domain-guides/accounts-and-authentication/understanding-companies-contacts-and-customers). ## Typical checkout sequence Here is the full sequence of API calls for a standard checkout: 1. **`viewer` query** — fetch stored addresses for logged-in users (skip for guests) 2. **`cartUpdateAddress` (DELIVERY)** — set the delivery address 3. **`cartUpdateAddress` (INVOICE)** — set the invoice address 4. **`cart` query** — fetch available shipping methods and carriers 5. **`cartUpdate` (postageData)** — set the shipping method and carrier 6. **`cart` query** — fetch available payment methods 7. **`cartUpdate` (paymentData)** — set the payment method 8. **`cart` query** — fetch the complete cart for final review 9. **`cartProcess`** — create the order 10. **Redirect to PSP** → handle payment → update order status (see [Payment integration](/frontend/domain-guides/cart-and-checkout/payment-integration)) Steps 4 and 6 can be combined into a single query if you fetch `shippingMethods`, `carriers` and `payMethods` at the same time. Similarly, step 8 can be combined with step 7 by requesting all review fields in the `cartUpdate` response. ## Next steps - [Payment integration](/frontend/domain-guides/cart-and-checkout/payment-integration) — track payments, handle PSP callbacks and confirm orders - [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) — create and manage cart items before checkout - [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle) — how orders, quotes and quote requests relate --- ## Payment Integration URL: https://docs.propeller-commerce.com/frontend/domain-guides/cart-and-checkout/payment-integration # Payment integration Propeller does not process payments. It stores payment records and transaction history while actual payment processing is handled by an external payment service provider (PSP) such as Mollie, Adyen or MultiSafepay. This page covers creating, updating and querying payment records in Propeller and confirming or failing orders based on the PSP result. For the steps leading up to order creation (addresses, shipping, payment method selection and `cartProcess`), see [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow). For order lifecycle concepts and status transitions, see [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle). ## How payments work in Propeller When a customer checks out with an online payment method, the order is created with status `UNFINISHED` (meaning it awaits payment). Your application then redirects the customer to the PSP. After the PSP processes the payment, it sends a callback to your backend. Your backend records the result in Propeller and confirms or fails the order. The typical flow: 1. `cartProcess` creates the order with status `UNFINISHED` 2. Your application redirects the customer to the PSP 3. The PSP processes the payment 4. The PSP sends a callback to your backend 5. `paymentCreate` records the payment in Propeller 6. `paymentUpdate` updates the payment when the PSP confirms the final result 7. `orderSetStatus` confirms the order (or marks it as failed) Each payment record in Propeller can contain multiple transactions. Transactions form an audit trail of every step in the payment lifecycle. For example, an authorization followed by a capture followed by a partial refund would produce three transaction records on the same payment. ## Payment statuses and transaction types ### Payment statuses The `PaymentStatuses` enum tracks the overall state of a payment record. | Status | Description | |---|---| | `OPEN` | Payment created, awaiting processing | | `PENDING` | Payment is being processed by the PSP | | `AUTHORIZED` | Payment authorized but not yet captured | | `CANCELLED` | Payment cancelled by the customer or merchant | | `EXPIRED` | Payment expired before completion | | `FAILED` | Payment attempt failed | | `PAID` | Payment successfully completed | | `REFUNDED` | Payment has been refunded | | `CHARGEBACK` | Payment reversed by the bank (chargeback) | ### Transaction types The `TransactionTypes` enum categorizes individual transactions within a payment. | Type | Description | |---|---| | `AUTHORIZATION` | Funds reserved on the customer's account | | `CANCEL_AUTHORIZATION` | Previously authorized funds released | | `PAY` | Funds captured or paid | | `REFUND` | Funds returned to the customer | | `CHARGEBACK` | Funds reversed by the bank | ### Transaction statuses The `TransactionStatuses` enum tracks the outcome of an individual transaction. | Status | Description | |---|---| | `OPEN` | Transaction initiated, awaiting result | | `PENDING` | Transaction is being processed | | `FAILED` | Transaction failed | | `SUCCESS` | Transaction completed successfully | ## Creating a payment record After initiating the payment with the PSP, create a payment record in Propeller using `paymentCreate`. This records the initial payment state and optionally includes the first transaction. > **Amounts are in cents.** The `amount` field is an integer representing the amount in the smallest currency unit. For example, €128.50 is `12850`. ### Mutation ```graphql mutation PaymentCreate($input: CreatePaymentInput!) { paymentCreate(input: $input) { id orderId amount currency method status paymentId transactions { id transactionId amount currency type status provider timestamp } createdAt } } ``` ### Variables ```json { "input": { "orderId": 534, "amount": 12850, "currency": "EUR", "method": "ideal", "status": "OPEN", "paymentId": "pay_9B7dCf3Hxk", "addTransaction": { "transactionId": "tr_WdjK5Nf4p2", "paymentId": "pay_9B7dCf3Hxk", "amount": 12850, "currency": "EUR", "type": "PAY", "status": "OPEN", "provider": "Mollie", "timestamp": "2025-11-03T14:22:08Z" } } } ``` ### Response ```json { "data": { "paymentCreate": { "id": "87", "orderId": 534, "amount": 12850, "currency": "EUR", "method": "ideal", "status": "OPEN", "paymentId": "pay_9B7dCf3Hxk", "transactions": [ { "id": "201", "transactionId": "tr_WdjK5Nf4p2", "amount": 12850, "currency": "EUR", "type": "PAY", "status": "OPEN", "provider": "Mollie", "timestamp": "2025-11-03T14:22:08Z" } ], "createdAt": "2025-11-03T14:22:09Z" } } } ``` ### CreatePaymentInput fields | Field | Type | Required | Description | |---|---|---|---| | `orderId` | `Int!` | Yes | The order ID returned by `cartProcess` (`cartOrderId`) | | `amount` | `Int!` | Yes | Payment amount in cents | | `currency` | `String!` | Yes | ISO 4217 currency code (e.g. `EUR`, `USD`) | | `method` | `String!` | Yes | PSP payment method identifier (e.g. `ideal`, `creditcard`) | | `status` | `PaymentStatuses!` | Yes | Initial payment status (typically `OPEN`) | | `paymentId` | `String` | No | PSP payment identifier to store for later reference | | `userId` | `Int` | No | Logged-in user ID | | `anonymousId` | `Int` | No | Guest user ID | | `addTransaction` | `CreateTransactionInput` | No | Initial transaction to record | ### CreateTransactionInput fields | Field | Type | Required | Description | |---|---|---|---| | `transactionId` | `String!` | Yes | PSP transaction identifier | | `amount` | `Int!` | Yes | Transaction amount in cents | | `currency` | `String!` | Yes | ISO 4217 currency code | | `type` | `TransactionTypes!` | Yes | Transaction type (`PAY`, `AUTHORIZATION`, `REFUND`, `CANCEL_AUTHORIZATION`, `CHARGEBACK`) | | `status` | `TransactionStatuses!` | Yes | Transaction status (`OPEN`, `PENDING`, `FAILED`, `SUCCESS`) | | `paymentId` | `String` | No | PSP payment identifier | | `description` | `String` | No | Description of the transaction | | `timestamp` | `DateTime` | No | When the transaction occurred at the PSP | | `provider` | `String` | No | PSP provider name (e.g. `Mollie`, `Adyen`) | ## Updating a payment When the PSP sends a callback confirming the payment result, update the payment record using `paymentUpdate`. The `searchBy` argument identifies which payment to update, and `addTransaction` appends a new transaction entry to the payment's history. ### Mutation ```graphql mutation PaymentUpdate($searchBy: SearchByInput!, $input: UpdatePaymentInput!) { paymentUpdate(searchBy: $searchBy, input: $input) { id orderId amount currency method status paymentId transactions { id transactionId amount currency type status provider timestamp } lastModifiedAt } } ``` ### Variables ```json { "searchBy": { "orderId": 534 }, "input": { "status": "PAID", "addTransaction": { "transactionId": "tr_WdjK5Nf4p2", "paymentId": "pay_9B7dCf3Hxk", "amount": 12850, "currency": "EUR", "type": "PAY", "status": "SUCCESS", "provider": "Mollie", "timestamp": "2025-11-03T14:23:41Z" } } } ``` ### Response ```json { "data": { "paymentUpdate": { "id": "87", "orderId": 534, "amount": 12850, "currency": "EUR", "method": "ideal", "status": "PAID", "paymentId": "pay_9B7dCf3Hxk", "transactions": [ { "id": "201", "transactionId": "tr_WdjK5Nf4p2", "amount": 12850, "currency": "EUR", "type": "PAY", "status": "OPEN", "provider": "Mollie", "timestamp": "2025-11-03T14:22:08Z" }, { "id": "202", "transactionId": "tr_WdjK5Nf4p2", "amount": 12850, "currency": "EUR", "type": "PAY", "status": "SUCCESS", "provider": "Mollie", "timestamp": "2025-11-03T14:23:41Z" } ], "lastModifiedAt": "2025-11-03T14:23:42Z" } } } ``` Notice that the response now contains two transactions. Each `addTransaction` appends a new entry. This gives you a complete audit trail of the payment lifecycle. ### SearchByInput fields The `searchBy` argument lets you identify the payment by different identifiers. Provide exactly one. | Field | Type | Description | |---|---|---| | `id` | `ID` | Propeller's internal payment identifier | | `paymentId` | `String` | PSP payment identifier (as stored via `paymentId` on create) | | `orderId` | `Float` | The order ID the payment belongs to | > The `orderId` field in `SearchByInput` is typed as `Float` in the schema. Pass the order ID as a number (e.g. `534`). ### UpdatePaymentInput fields All fields are optional. Only the fields you provide will be updated. | Field | Type | Description | |---|---|---| | `status` | `PaymentStatuses` | Updated payment status | | `amount` | `Int` | Updated amount in cents | | `currency` | `String` | Updated currency code | | `method` | `String` | Updated payment method | | `paymentId` | `String` | Updated PSP payment identifier | | `userId` | `Int` | Updated user ID | | `anonymousId` | `Int` | Updated guest user ID | | `addTransaction` | `CreateTransactionInput` | New transaction to append | ## Confirming the order After a successful payment, transition the order from `UNFINISHED` to `NEW` (or another confirmed status) using `orderSetStatus`. This also sets the payment status on the order and can trigger a confirmation email. ### Mutation ```graphql mutation OrderSetStatus($input: OrderSetStatusInput!) { orderSetStatus(input: $input) { id status paymentData { status statusDate } } } ``` ### Variables ```json { "input": { "orderId": 534, "status": "NEW", "payStatus": "PAID", "sendOrderConfirmationEmail": true, "addPDFAttachment": true, "deleteCart": true } } ``` ### Response ```json { "data": { "orderSetStatus": { "id": 534, "status": "NEW", "paymentData": { "status": "PAID", "statusDate": "2025-11-03T14:23:45Z" } } } } ``` ### OrderSetStatusInput fields | Field | Type | Required | Description | |---|---|---|---| | `orderId` | `Int!` | Yes | The order to update | | `status` | `String` | No | New order status code (e.g. `NEW`) | | `payStatus` | `String` | No | Payment status to set on the order (e.g. `PAID`, `OPEN`, `FAILED`) | | `sendOrderConfirmationEmail` | `Boolean` | No | Send a confirmation email to the customer (default: `false`) | | `addPDFAttachment` | `Boolean` | No | Attach a PDF invoice to the confirmation email (default: `false`) | | `triggerOrderSendConfirmEvent` | `Boolean` | No | Trigger an `ORDER_SEND_CONFIRMATION` event via the Event Action Manager (default: `false`) | | `deleteCart` | `Boolean` | No | Delete the originating cart after confirmation (default: `false`) | > The `status` transition must be allowed by the configured status workflow. If you attempt a transition that is not configured, the API returns an `ORDER_STATUS_TRANSITION_NOT_ALLOWED` error. See [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle) for details on status transitions. ## Handling failed payments When the PSP reports that a payment failed (customer cancelled, card declined, timeout), update the payment record and the order status to reflect the failure. ### Update the payment ```graphql mutation PaymentUpdate($searchBy: SearchByInput!, $input: UpdatePaymentInput!) { paymentUpdate(searchBy: $searchBy, input: $input) { id orderId status transactions { id transactionId type status timestamp } } } ``` ```json { "searchBy": { "orderId": 534 }, "input": { "status": "CANCELLED", "addTransaction": { "transactionId": "tr_WdjK5Nf4p2", "paymentId": "pay_9B7dCf3Hxk", "amount": 12850, "currency": "EUR", "type": "PAY", "status": "FAILED", "provider": "Mollie", "timestamp": "2025-11-03T14:25:12Z", "description": "Payment cancelled by customer" } } } ``` Expected response: ```json { "data": { "paymentUpdate": { "id": "8", "orderId": 534, "status": "CANCELLED", "transactions": [ { "id": "12", "transactionId": "tr_QrPm8Xk3v1", "type": "PAY", "status": "OPEN", "timestamp": "2025-11-03T14:20:00.000Z" }, { "id": "13", "transactionId": "tr_WdjK5Nf4p2", "type": "PAY", "status": "FAILED", "timestamp": "2025-11-03T14:25:12.000Z" } ] } } } ``` Use the payment status that best matches the PSP result. For example, use `CANCELLED` when the customer cancelled, `EXPIRED` when the payment window timed out and `FAILED` for a declined card. ### Update the order status ```graphql mutation OrderSetStatus($input: OrderSetStatusInput!) { orderSetStatus(input: $input) { id status paymentData { status } } } ``` ```json { "input": { "orderId": 534, "status": "NEW", "payStatus": "FAILED" } } ``` Expected response: ```json { "data": { "orderSetStatus": { "id": 534, "status": "NEW", "paymentData": { "status": "FAILED" } } } } ``` ### Retrying a payment To let the customer retry payment after a failure, create a new payment record for the same order using `paymentCreate`. The previous payment and its transactions remain in the history as an audit trail. Redirect the customer to the PSP again and follow the same flow. ## Refunds To record a refund, update the payment with a `REFUND` transaction and set the payment status to `REFUNDED`. ### Mutation ```graphql mutation PaymentUpdate($searchBy: SearchByInput!, $input: UpdatePaymentInput!) { paymentUpdate(searchBy: $searchBy, input: $input) { id orderId status transactions { id transactionId amount type status timestamp } } } ``` ### Variables ```json { "searchBy": { "orderId": 534 }, "input": { "status": "REFUNDED", "addTransaction": { "transactionId": "tr_Rf8mN2Kp7v", "paymentId": "pay_9B7dCf3Hxk", "amount": 12850, "currency": "EUR", "type": "REFUND", "status": "SUCCESS", "provider": "Mollie", "timestamp": "2025-11-05T09:15:30Z", "description": "Full refund for order 534" } } } ``` ### Response ```json { "data": { "paymentUpdate": { "id": "87", "orderId": 534, "status": "REFUNDED", "transactions": [ { "id": "201", "transactionId": "tr_WdjK5Nf4p2", "amount": 12850, "type": "PAY", "status": "OPEN", "timestamp": "2025-11-03T14:22:08Z" }, { "id": "202", "transactionId": "tr_WdjK5Nf4p2", "amount": 12850, "type": "PAY", "status": "SUCCESS", "timestamp": "2025-11-03T14:23:41Z" }, { "id": "203", "transactionId": "tr_Rf8mN2Kp7v", "amount": 12850, "type": "REFUND", "status": "SUCCESS", "timestamp": "2025-11-05T09:15:30Z" } ] } } } ``` For partial refunds, set the transaction `amount` to the refunded amount in cents rather than the full payment amount. ## Pay-on-account flow For B2B customers who pay on account, no PSP integration is needed. The order is created with a confirmed status directly and no payment records are required. Use `cartProcess` with status `NEW` instead of `UNFINISHED`: ```graphql mutation CartProcess($id: String!, $input: CartProcessInput!) { cartProcess(id: $id, input: $input) { cart { cartId } order { id status } cartOrderId } } ``` ```json { "id": "018dcc9a-f965-7434-8fad-369aa9a8c276", "input": { "orderStatus": "NEW" } } ``` Expected response: ```json { "data": { "cartProcess": { "cart": { "cartId": "018dcc9a-f965-7434-8fad-369aa9a8c276" }, "order": { "id": 534, "status": "NEW" }, "cartOrderId": 534 } } } ``` Then confirm the order and trigger the confirmation email: ```graphql mutation OrderSetStatus($input: OrderSetStatusInput!) { orderSetStatus(input: $input) { id status paymentData { status } } } ``` ```json { "input": { "orderId": 534, "status": "NEW", "payStatus": "OPEN", "sendOrderConfirmationEmail": true, "addPDFAttachment": true, "deleteCart": true } } ``` Expected response: ```json { "data": { "orderSetStatus": { "id": 534, "status": "NEW", "paymentData": { "status": "OPEN" } } } } ``` No `paymentCreate` or `paymentUpdate` calls are needed. The order is confirmed with payment status `OPEN`, indicating that payment will be handled outside the checkout flow (e.g. via invoice). ## Fetching payment records ### Fetch a single payment Use the `payment` query to retrieve a specific payment record. The `searchBy` argument accepts the same fields as `paymentUpdate` (Propeller `id`, PSP `paymentId` or `orderId`). #### Query ```graphql query Payment($searchBy: SearchByInput!) { payment(searchBy: $searchBy) { id orderId amount currency method status paymentId userId anonymousId transactions { id transactionId orderId amount currency type status paymentId description provider timestamp } createdAt createdBy lastModifiedAt lastModifiedBy } } ``` #### Variables ```json { "searchBy": { "orderId": 534 } } ``` #### Response ```json { "data": { "payment": { "id": "87", "orderId": 534, "amount": 12850, "currency": "EUR", "method": "ideal", "status": "PAID", "paymentId": "pay_9B7dCf3Hxk", "userId": null, "anonymousId": null, "transactions": [ { "id": "201", "transactionId": "tr_WdjK5Nf4p2", "orderId": 534, "amount": 12850, "currency": "EUR", "type": "PAY", "status": "OPEN", "paymentId": "pay_9B7dCf3Hxk", "description": null, "provider": "Mollie", "timestamp": "2025-11-03T14:22:08Z" }, { "id": "202", "transactionId": "tr_WdjK5Nf4p2", "orderId": 534, "amount": 12850, "currency": "EUR", "type": "PAY", "status": "SUCCESS", "paymentId": "pay_9B7dCf3Hxk", "description": null, "provider": "Mollie", "timestamp": "2025-11-03T14:23:41Z" } ], "createdAt": "2025-11-03T14:22:09Z", "createdBy": "system", "lastModifiedAt": "2025-11-03T14:23:42Z", "lastModifiedBy": "system" } } } ``` The `payment` query returns `null` when no payment is found for the given search criteria. ### List payments Use the `payments` query to list payment records with pagination. #### Query ```graphql query Payments($input: PaymentsSearchInput) { payments(input: $input) { items { id orderId amount currency method status paymentId createdAt } itemsFound page pages offset start end } } ``` #### Variables ```json { "input": { "page": 1, "offset": 12 } } ``` #### Response ```json { "data": { "payments": { "items": [ { "id": "87", "orderId": 534, "amount": 12850, "currency": "EUR", "method": "ideal", "status": "PAID", "paymentId": "pay_9B7dCf3Hxk", "createdAt": "2025-11-03T14:22:09Z" }, { "id": "86", "orderId": 521, "amount": 4500, "currency": "EUR", "method": "banktransfer", "status": "PAID", "paymentId": "pay_3Hk7mWn9Rp", "createdAt": "2025-10-28T10:05:22Z" } ], "itemsFound": 2, "page": 1, "pages": 1, "offset": 12, "start": 0, "end": 2 } } } ``` ## Deleting a payment record Use `paymentDelete` to remove a payment record. The `searchBy` argument works the same way as in `paymentUpdate`. ### Mutation ```graphql mutation PaymentDelete($searchBy: SearchByInput!) { paymentDelete(searchBy: $searchBy) { id orderId status } } ``` ### Variables ```json { "searchBy": { "id": "87" } } ``` ### Response ```json { "data": { "paymentDelete": { "id": "87", "orderId": 534, "status": "PAID" } } } ``` The mutation returns the deleted payment record. ## Payment flow summary ### Online payment (iDEAL, credit card, etc.) ``` cartProcess (UNFINISHED) → redirect to PSP → paymentCreate (OPEN) → PSP callback (success) → paymentUpdate (PAID) → orderSetStatus (NEW / PAID) ``` ### Pay on account ``` cartProcess (NEW) → orderSetStatus (NEW / OPEN) ``` ### Failed payment ``` cartProcess (UNFINISHED) → redirect to PSP → paymentCreate (OPEN) → PSP callback (failed) → paymentUpdate (CANCELLED or FAILED) → orderSetStatus (UNSUCCESSFUL / FAILED) → optionally retry with a new paymentCreate ``` ## Next steps - [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow) for setting addresses, shipping and payment on the cart before order creation - [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle) for order statuses, transitions and the quote-to-order lifecycle - [Order history](/frontend/domain-guides/orders-and-shipments/order-history) for displaying order history to customers --- ## Understanding the Order Lifecycle URL: https://docs.propeller-commerce.com/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle # Understanding the order lifecycle In Propeller, orders, quotes and quote requests are all the same object: an **Order**. What distinguishes them is the combination of `type` and `status` on the order. This unified model means you query, display and manage all three using the same API. Understanding how the order lifecycle works helps you build checkout flows, quote request forms, order history pages and status displays correctly. ## The unified order model Every order in Propeller has two fields that define what it represents: - **`type`** (OrderType) categorizes the order by its business purpose. - **`status`** (a string matching a configured OrderStatus code) determines where the order sits in its lifecycle. ### OrderType The `type` field is an enum with four values: | Type | Purpose | |---|---| | `dropshipment` | A standard order fulfilled by shipping to the customer | | `quotation` | A quote or quote request | | `purchase` | A purchase order (procurement) | | `stock` | A stock order (internal inventory) | For most frontend applications, you work with `dropshipment` (orders) and `quotation` (quotes and quote requests). ### OrderStatus Each order status is a configured object with its own fields: | Field | Description | |---|---| | `code` | The status identifier (e.g. `NEW`, `QUOTATION`, `SHIPPED`) | | `name` | Human-readable label | | `orderType` | Categorizes the status as `ORDER`, `QUOTATION` or `REQUEST` | | `type` | Whether the status is `SYSTEM` (built-in) or `CUSTOM` (user-defined) | | `isPublic` | Whether the status is visible to customers | | `isDefault` | Whether this is the default status for its order type | | `nextStatuses` | Which statuses this status can transition to | | `previousStatuses` | Which statuses can transition to this status | | `statusSet` | The parent group this status belongs to | The `orderType` field on a status is what ultimately tells you whether an order is a quote request (`REQUEST`), a quote (`QUOTATION`) or an order (`ORDER`). ### How type and status combine | What it represents | OrderType | OrderStatus orderType | Status examples | |---|---|---|---| | Quote request | `quotation` | `REQUEST` | `QUOTE_REQUEST` | | Draft quote | `quotation` | `QUOTATION` | `DRAFT_QUOTATION` | | Published quote | `quotation` | `QUOTATION` | `QUOTATION` | | Order | `dropshipment` | `ORDER` | `NEW`, `PROCESSING`, `SHIPPED` | Because quotes and orders share the same data model, you query them using the same `orders` query and filter by `type` or `status` to display the right records in the right context. ## How orders are created There are three paths to creating an order. Each produces the same Order object but serves a different use case. ### Cart (storefront checkout) The most common path for customer-facing applications. A customer builds a cart, sets addresses, selects shipping and payment, then converts the cart into an order using `cartProcess`. ``` cartStart → add items → set addresses → choose shipping → choose payment → cartProcess ``` The `cartProcess` mutation takes an `orderStatus` (typically `UNFINISHED` for orders requiring payment or `NEW` for orders on account) and returns the created order with its `cartOrderId`. This is the path covered in [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) and [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow). ### Tender (sales portal) A tender is an extended cart used by sales representatives. It allows adjusting item prices, applying discounts, viewing margins and managing customer-specific pricing. Tenders are typically used in a sales portal, not in a frontend customer portal. ``` tenderStart → add items → adjust pricing and discounts → tenderProcess ``` Key differences from a cart: | Aspect | Cart | Tender | |---|---|---| | User | Customer | Sales representative | | Pricing | Fixed (based on price sheets and rules) | Adjustable per item | | Discounts | Applied via action codes | Manually set with margin visibility | | Owner | The customer placing the order | The sales rep (`ownerId`), separate from the customer | | Output | `cartProcess` → order | `tenderProcess` → order | When a tender is processed, it creates the same Order object as a cart would. The `source` field on the resulting order indicates where it originated (e.g. "Sales Portal"). ### Direct creation (API and admin) For integrations and backend systems, orders can be created directly using the `orderCreate` mutation. This creates a complete order in a single call without going through the cart or tender flow. ### All paths converge Regardless of how an order is created, the result is the same Order object. The `cartId` field on an order indicates whether it originated from a cart. The `source` field records where the order came from. ``` Cart (storefront) ──→ Tender (sales portal) ──→ Order orderCreate (API) ──→ ``` ## Order statuses and transitions Order statuses in Propeller are not just labels. They form a directed graph of allowed transitions. Each status defines which other statuses it can move to (`nextStatuses`) and which statuses can move to it (`previousStatuses`). ### Status sets Statuses are organized into sets (OrderStatusSet). A set is a named group of related statuses. This is useful for workflow organization, for example grouping all quote-related statuses into one set and all fulfillment statuses into another. ### Common statuses These are common system statuses found in a typical Propeller environment. Statuses are fully configurable, so your environment may have different codes and transitions. | Status code | Order type | Description | |---|---|---| | `QUOTE_REQUEST` | `REQUEST` | Customer submitted a quote request | | `DRAFT_QUOTATION` | `QUOTATION` | Sales rep is drafting a quote (not visible to customer) | | `QUOTATION` | `QUOTATION` | Published quote, visible to customer | | `UNFINISHED` | `ORDER` | Order created, awaiting payment | | `NEW` | `ORDER` | Confirmed order | | `PROCESSING` | `ORDER` | Order is being processed | | `SHIPPED` | `ORDER` | Order has been shipped | | `COMPLETED` | `ORDER` | Order delivered and completed | ### Enforced transitions The `orderSetStatus` mutation enforces valid transitions. If you attempt a transition that is not allowed by the status configuration, the API returns an `ORDER_STATUS_TRANSITION_NOT_ALLOWED` error. The `orderSetStatus` input accepts: | Field | Description | |---|---| | `orderId` | The order to update | | `status` | The new status code | | `payStatus` | Optional payment status update | | `sendOrderConfirmationEmail` | Whether to send a confirmation email | | `addPDFAttachment` | Whether to attach a PDF to the email | | `deleteCart` | Whether to delete the originating cart | ### Public vs. internal statuses The `isPublic` flag on a status controls whether it is visible to customers. Internal statuses (where `isPublic` is false) are only visible to admin users and sales representatives. Use this flag to decide which statuses to display in a customer-facing order history page. ## Example lifecycle: quote to order This example walks through a complete lifecycle from quote request to fulfilled order, showing how the same Order object moves through different statuses. **1. Customer submits a quote request** A customer builds a cart and processes it with a quote request status. The resulting order has type `quotation` and status `QUOTE_REQUEST`. **2. Sales rep creates a draft quote** A sales representative opens the order in the sales portal and adjusts pricing and discounts. The status moves to `DRAFT_QUOTATION`. This status is not public, so the customer cannot see the draft. **3. Sales rep publishes the quote** When the quote is ready, the sales rep transitions the status to `QUOTATION`. This status is public, so the customer can now view the quote and its pricing. The quote may have a `validUntil` date after which it expires. **4. Sales rep revises the quote** If changes are needed, the sales rep transitions back to `DRAFT_QUOTATION`, makes adjustments and publishes again. Each change is tracked in the version history (see below). **5. Customer accepts the quote** The customer accepts the quote, which transitions the status from `QUOTATION` to an order status like `NEW`. At this point the order type remains `quotation` but the status order type changes from `QUOTATION` to `ORDER`. **6. Order moves through fulfillment** The order progresses through fulfillment statuses: `PROCESSING` → `SHIPPED` → `COMPLETED`. Shipments are created and tracked along the way. ``` QUOTE_REQUEST → DRAFT_QUOTATION → QUOTATION → DRAFT_QUOTATION → QUOTATION → NEW → PROCESSING → SHIPPED → COMPLETED (1) (2) (3) (4) (4) (5) (6) (6) (6) ``` ## Version history Orders track every change as a revision. Each revision stores a sequential number, a timestamp, who made the change and a snapshot of the order state at that point. ### Revision fields | Field | Description | |---|---| | `revisionNumber` | Sequential number, starting at 1 | | `createdAt` | When the revision was created | | `createdByAdminUser` | The admin user who made the change (if applicable) | | `createdByContact` | The contact who made the change (if applicable) | | `createdByCustomer` | The customer who made the change (if applicable) | | `createdFromRevisionNumber` | The revision this one was branched from | ### Snapshots Each revision contains a snapshot of the order state, including the status, type and visibility flags. Two fields are particularly relevant for frontends: - **`public`**: whether this revision is visible to customers. - **`publicVersionNumber`**: the version number that customers see. Public revisions get an incrementing `publicVersionNumber`. Private revisions (like draft edits) do not increment this number and are not visible to customers. ### Example revision trail For the quote-to-order lifecycle described above, the revision history might look like this: | Revision | Status | Public | Customer sees | |---|---|---|---| | 1 | `DRAFT_QUOTATION` | No | Nothing | | 2 | `QUOTATION` | Yes | Version 1 | | 3 | `DRAFT_QUOTATION` | No | Still version 1 | | 4 | `QUOTATION` | Yes | Version 2 | | 5 | `NEW` | Yes | Version 3 (order confirmed) | ### Revision mutations Two mutations manage revisions: - **`orderRevisionRestore`** restores an order to a previous revision state. Takes `orderId` and `revisionNumber`. - **`orderRevisionsInvalidate`** marks specific revisions as invalid. ## Shipment tracking After an order is confirmed, shipments track the physical fulfillment. Each shipment has a status that progresses through delivery stages: | Status | Description | |---|---| | `CREATED` | Shipment record created | | `PROCESSING` | Being prepared | | `IN_TRANSIT` | Picked up by carrier | | `OUT_FOR_DELIVERY` | Out for final delivery | | `DELIVERED` | Successfully delivered | | `PARTIALLY_DELIVERED` | Some items delivered | | `FAILED_DELIVERY` | Delivery attempt failed | | `CANCELED` | Shipment canceled | | `EXCEPTION` | An exception occurred | An order can have multiple shipments for partial deliveries. Each shipment can carry track-and-trace codes for carrier tracking. ## Key fields on the Order object These are the fields most relevant to understanding the order lifecycle. For complete field documentation, see the [GraphQL API reference](/reference/graphql). | Field | Description | |---|---| | `id` | Auto-increment order ID | | `uuid` | UUID identifier | | `status` | Current status code | | `type` | Order type (`dropshipment`, `quotation`, `purchase`, `stock`) | | `source` | Where the order was created (e.g. webshop name, "Sales Portal") | | `cartId` | The originating cart ID, if created via `cartProcess` | | `originalOrderId` | Reference to the original order (for copies) | | `validUntil` | Expiry date (used for quotes) | | `createdAt` | Creation timestamp | | `lastModifiedAt` | Last modification timestamp | | `statusDate` | When the status was last changed | | `companyId` | The company this order belongs to | | `invoiceUserId` | The user responsible for payment | | `items` | The order line items | | `total` | Order totals (subtotal, tax, shipping, grand total) | | `orderAddresses` | Billing and shipping addresses | | `shipments` | Associated shipments | ## What this means for your frontend Understanding the order lifecycle helps you build the right features for each stage: 1. **Cart management** creates and manages the mutable cart before checkout. See [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management). 2. **Checkout flow** sets addresses, shipping and payment on the cart and converts it to an order with `cartProcess`. See [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow). 3. **Quote requests** use `cartProcess` with a quote request status to submit the cart as a request for quotation. 4. **Order history** queries orders and filters by type or status to display the right records. Use `isPublic` on statuses to decide which statuses to show customers. See [Order history](/frontend/domain-guides/orders-and-shipments/order-history). 5. **Payment integration** handles the payment flow after `cartProcess` creates an order with status `UNFINISHED`. See [Payment integration](/frontend/domain-guides/cart-and-checkout/payment-integration). 6. **Shipment tracking** displays shipment statuses and track-and-trace codes for fulfilled orders. --- ## Order History URL: https://docs.propeller-commerce.com/frontend/domain-guides/orders-and-shipments/order-history # Order history Fetch and display orders for a customer or contact in your portal. The `orders` query returns orders for the authenticated user automatically based on the access token. You do not need to pass a user ID. For B2B portals, use the `companyIds` filter to show all orders placed by colleagues within the same company. For order status transitions and the overall lifecycle, see [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle). For shipment details, see [Shipment tracking](/frontend/domain-guides/orders-and-shipments/shipment-tracking). ## Listing orders Use the `orders` query with the fields you need for a list view. The query returns a paginated response with order summaries. ### Query ```graphql query Orders($input: OrderSearchArguments) { orders(input: $input) { items { id uuid status type createdAt currency reference companyId total { gross net tax } } itemsFound offset page pages start end } } ``` ### Variables ```json { "input": { "page": 1, "offset": 10 } } ``` ### Response ```json { "data": { "orders": { "items": [ { "id": 277, "uuid": "019cb812-4a03-7128-a1f7-6e2d3b8c9f01", "status": "NEW", "type": "dropshipment", "createdAt": "2026-02-22T21:23:00.402Z", "currency": "EUR", "reference": null, "companyId": null, "total": { "gross": 695.08, "net": 841.05, "tax": 145.97 } }, { "id": 244, "uuid": "019c7543-8693-7954-bdc5-76acae4f8c92", "status": "CONFIRMED", "type": "dropshipment", "createdAt": "2026-02-19T09:38:05.649Z", "currency": "EUR", "reference": null, "companyId": 4, "total": { "gross": 3528.88, "net": 4269.94, "tax": 741.06 } }, { "id": 126, "uuid": "019b9e01-c2d4-7e12-b5a3-1f8d4c6e2a09", "status": "QUOTATION", "type": "quotation", "createdAt": "2026-02-04T09:12:02.172Z", "currency": "EUR", "reference": null, "companyId": 5, "total": { "gross": 1667.00, "net": 2017.07, "tax": 350.07 } } ], "itemsFound": 236, "offset": 10, "page": 1, "pages": 24, "start": 1, "end": 10 } } } ``` The `total.gross` field is the order total excluding tax. The `total.net` field is the order total including tax. > The `date` field on orders is deprecated. Use `createdAt` instead. ### Pagination fields | Field | Type | Description | |---|---|---| | `itemsFound` | `Int!` | Total number of orders matching the filters | | `offset` | `Int!` | Number of items per page | | `page` | `Int!` | Current page number | | `pages` | `Int!` | Total number of pages | | `start` | `Int!` | Start position of the current page | | `end` | `Int!` | End position of the current page | ## Pagination Control the page size with `offset` and navigate pages with `page`. The default offset is 12. ### Query ```graphql query OrdersPaginated($input: OrderSearchArguments) { orders(input: $input) { items { id status createdAt total { gross net } } itemsFound page pages } } ``` ### Variables ```json { "input": { "page": 2, "offset": 5 } } ``` ### Response ```json { "data": { "orders": { "items": [ { "id": 242, "status": "CONFIRMED", "createdAt": "2026-02-09T12:40:24.081Z", "total": { "gross": 583.67, "net": 706.24 } }, { "id": 243, "status": "NEW", "createdAt": "2026-02-09T14:21:37.188Z", "total": { "gross": 412.30, "net": 498.88 } } ], "itemsFound": 236, "page": 2, "pages": 48 } } } ``` ## Sorting orders Use the `sortInputs` field to control the order of results. Each sort input takes a `field` and an `order` (ASC or DESC). ### Query ```graphql query OrdersSorted($input: OrderSearchArguments) { orders(input: $input) { items { id status createdAt total { gross } } itemsFound } } ``` ### Variables ```json { "input": { "sortInputs": [{ "field": "CREATED_AT", "order": "DESC" }], "page": 1, "offset": 3 } } ``` ### Response ```json { "data": { "orders": { "items": [ { "id": 277, "status": "NEW", "createdAt": "2026-02-22T21:23:00.402Z", "total": { "gross": 695.08 } }, { "id": 244, "status": "CONFIRMED", "createdAt": "2026-02-19T09:38:05.649Z", "total": { "gross": 3528.88 } }, { "id": 243, "status": "NEW", "createdAt": "2026-02-09T14:21:37.188Z", "total": { "gross": 412.30 } } ], "itemsFound": 236 } } } ``` ### Sort fields | Field | Description | |---|---| | `ID` | Sort by order ID | | `CREATED_AT` | Sort by creation date | | `LAST_MODIFIED_AT` | Sort by last modification date | | `STATUS` | Sort by status alphabetically | | `COMPANY` | Sort by company name | | `TOTAL_GROSS` | Sort by order total (excluding tax) | | `VALID_UNTIL` | Sort by validity expiration date | ## Filtering orders The `OrderSearchArguments` input supports multiple filters. You can combine them in a single query. ### By status Pass one or more status codes to filter orders by their current status. Status codes are strings like `NEW`, `CONFIRMED`, `QUOTATION` and `ARCHIVED`. For all available statuses and their meaning, see [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle). #### Query ```graphql query OrdersByStatus($input: OrderSearchArguments) { orders(input: $input) { items { id status type createdAt total { gross net } } itemsFound } } ``` #### Variables ```json { "input": { "status": ["CONFIRMED"], "sortInputs": [{ "field": "CREATED_AT", "order": "DESC" }], "page": 1, "offset": 3 } } ``` #### Response ```json { "data": { "orders": { "items": [ { "id": 244, "status": "CONFIRMED", "type": "dropshipment", "createdAt": "2026-02-19T09:38:05.649Z", "total": { "gross": 3528.88, "net": 4269.94 } }, { "id": 242, "status": "CONFIRMED", "type": "dropshipment", "createdAt": "2026-02-09T12:40:24.081Z", "total": { "gross": 583.67, "net": 706.24 } } ], "itemsFound": 66 } } } ``` ### By date range Use the `createdAt` field with `greaterThan` and `lessThan` to filter by date range. Both values are date strings. #### Variables ```json { "input": { "createdAt": { "greaterThan": "2026-02-01", "lessThan": "2026-02-28" } } } ``` You can also use `lastModifiedAt` with the same `greaterThan` and `lessThan` structure to filter by when orders were last updated. ### By company (B2B) For B2B portals where contacts can view orders placed by anyone in their company, filter by `companyIds`. In B2B, orders belong to the company, not the individual. A procurement manager needs to see all orders placed by any contact in the company to track spending across departments and follow up on deliveries. The `companyIds` filter enables this company-wide view. Without it, a contact only sees their own orders. #### Variables ```json { "input": { "companyIds": [4], "page": 1, "offset": 3 } } ``` #### Response ```json { "data": { "orders": { "items": [ { "id": 244, "status": "CONFIRMED", "createdAt": "2026-02-19T09:38:05.649Z", "companyId": 4 }, { "id": 243, "status": "NEW", "createdAt": "2026-02-09T14:21:37.188Z", "companyId": 4 } ], "itemsFound": 117 } } } ``` ### By order type Filter by `type` to separate regular orders from quotations or other order types. | Type | Description | |---|---| | `dropshipment` | Standard order | | `purchase` | Purchase order | | `quotation` | Quotation or price request | | `stock` | Stock order | #### Variables ```json { "input": { "type": ["quotation"] } } ``` ### By price range Use the `price` field to filter orders by their total. The `price` input supports `greaterThan`, `lessThan` and `equal`. #### Variables ```json { "input": { "price": { "greaterThan": 500, "lessThan": 5000 } } } ``` ### By channel The `orders` query returns orders from all channels by default. In multi-channel setups where the same user may have orders across different storefronts, pass `channelId` as an input parameter to filter orders to a specific channel. ## Searching orders Use `term` with `termFields` to search across order data. The `term` performs a text search and `termFields` specifies which fields to match against. If `termFields` is omitted, the search runs across multiple fields. ### Query ```graphql query SearchOrders($input: OrderSearchArguments) { orders(input: $input) { items { id status createdAt items { name sku quantity } } itemsFound } } ``` ### Variables ```json { "input": { "term": "EcoSpot", "termFields": ["ITEM_NAME"] } } ``` ### Response ```json { "data": { "orders": { "items": [ { "id": 244, "status": "CONFIRMED", "createdAt": "2026-02-19T09:38:05.649Z", "items": [ { "name": "EcoSpot Recessed Downlight", "sku": "EL-ESD1", "quantity": 10 }, { "name": "Eclipse Elite LED Desk Lamp", "sku": "LL-EEDL01", "quantity": 5 } ] }, { "id": 243, "status": "NEW", "createdAt": "2026-02-09T14:21:37.188Z", "items": [ { "name": "Hidden Glow Wall Washer", "sku": "GT-HGW1", "quantity": 1 }, { "name": "EcoSpot Recessed Downlight", "sku": "EL-ESD1", "quantity": 1 } ] } ], "itemsFound": 21 } } } ``` ### Search fields The most commonly used search fields for a customer portal: | Field | Description | |---|---| | `ID` | Order ID | | `REFERENCE` | Customer reference number | | `ITEM_NAME` | Product name on the order line | | `ITEM_SKU` | Product SKU on the order line | | `RECIPIENT_FIRST_NAME` | First name on the delivery address | | `RECIPIENT_LAST_NAME` | Last name on the delivery address | | `RECIPIENT_COMPANY` | Company name on the delivery address | | `REMARKS` | Order remarks | Other available fields include `INVOICE_ADDRESS_COMPANY`, `INVOICE_ADDRESS_FIRST_NAME`, `INVOICE_ADDRESS_LAST_NAME`, `RECIPIENT_EMAIL`, `ITEM_EAN_CODE`, `ITEM_MANUFACTURER`, `ITEM_SUPPLIER`, `ACCOUNTING_ID` and `DEBTOR_ID`. ## Fetching a single order ### By order ID Use the `order` query to fetch full details for a single order. This includes items, addresses, totals, shipping and payment data. #### Query ```graphql query OrderDetail($orderId: Int!) { order(orderId: $orderId) { id uuid status type createdAt lastModifiedAt email reference remarks currency language companyId total { gross net tax discountType discountValue taxPercentages { percentage total } } items { id class productId sku name quantity price priceNet priceTotal priceTotalNet tax taxPercentage discount originalPrice notes product { productId names { value language } sku slugs { value language } } } deliveryAddress: addresses(type: delivery) { firstName middleName lastName company street number numberExtension postalCode city region country phone email } invoiceAddress: addresses(type: invoice) { firstName middleName lastName company street number numberExtension postalCode city region country phone email } postageData { method gross net tax taxPercentage carrier partialDeliveryAllowed requestDate } paymentData { method status gross net tax taxPercentage } } } ``` #### Variables ```json { "orderId": 244 } ``` #### Response ```json { "data": { "order": { "id": 244, "uuid": "019c7543-8693-7954-bdc5-76acae4f8c92", "status": "CONFIRMED", "type": "dropshipment", "createdAt": "2026-02-19T09:38:05.649Z", "lastModifiedAt": "2026-02-19T09:53:41.263Z", "email": "lisa@brouwerindustrie.nl", "reference": null, "remarks": null, "currency": "EUR", "language": "NL", "companyId": 4, "total": { "gross": 3528.88, "net": 4269.94, "tax": 741.06, "discountType": "N", "discountValue": 0, "taxPercentages": [ { "percentage": 21, "total": 741.06 } ] }, "items": [ { "id": 26987, "class": "product", "productId": 44, "sku": "EL-ESD1", "name": "EcoSpot Recessed Downlight", "quantity": 10, "price": 309.14, "priceNet": 374.06, "priceTotal": 3091.42, "priceTotalNet": 3740.62, "tax": 649.20, "taxPercentage": 21, "discount": 33.40, "originalPrice": 342.54, "notes": "", "product": { "productId": 44, "names": [ { "value": "EcoSpot Recessed Downlight", "language": "NL" } ], "sku": "EL-ESD1", "slugs": [ { "value": "ecospot-recessed-downlight", "language": "NL" } ] } }, { "id": 26992, "class": "product", "productId": 12, "sku": "LL-EEDL01", "name": "Eclipse Elite LED Desk Lamp", "quantity": 5, "price": 85.49, "priceNet": 103.44, "priceTotal": 427.45, "priceTotalNet": 517.22, "tax": 89.77, "taxPercentage": 21, "discount": 4.50, "originalPrice": 89.99, "notes": "", "product": { "productId": 12, "names": [ { "value": "Eclipse Elite LED Desk Lamp", "language": "NL" } ], "sku": "LL-EEDL01", "slugs": [ { "value": "eclipse-elite-led-desk-lamp", "language": "NL" } ] } } ], "deliveryAddress": [ { "firstName": "Lisa", "middleName": null, "lastName": "de Vries", "company": "Brouwer Industrie", "street": "Keizersgracht", "number": "112", "numberExtension": null, "postalCode": "1016 GE", "city": "Amsterdam", "region": null, "country": "NL", "phone": "+31 20 123 4567", "email": "lisa@brouwerindustrie.nl" } ], "invoiceAddress": [ { "firstName": "Lisa", "middleName": null, "lastName": "de Vries", "company": "Brouwer Industrie", "street": "Keizersgracht", "number": "112", "numberExtension": null, "postalCode": "1016 GE", "city": "Amsterdam", "region": null, "country": "NL", "phone": "+31 20 123 4567", "email": "finance@brouwerindustrie.nl" } ], "postageData": { "method": "REGULAR", "gross": 10.00, "net": 12.10, "tax": 2.10, "taxPercentage": 21, "carrier": null, "partialDeliveryAllowed": "N", "requestDate": "2026-02-06T09:20:53.728Z" }, "paymentData": { "method": "ACCOUNT", "status": "UNKNOWN", "gross": 0, "net": 0, "tax": 0, "taxPercentage": 21 } } } } ``` ### By UUID You can also fetch an order by its UUID using the `orderUUID` parameter. This is useful when you use UUIDs in your frontend URLs instead of numeric IDs. ```graphql query OrderByUUID($orderUUID: String!) { order(orderUUID: $orderUUID) { id status createdAt } } ``` ```json { "orderUUID": "019c7543-8693-7954-bdc5-76acae4f8c92" } ``` #### Response ```json { "data": { "order": { "id": 244, "status": "CONFIRMED", "createdAt": "2026-02-19T09:38:05.649Z" } } } ``` The `order` query accepts either `orderId` or `orderUUID`. You do not need to provide both. ## Order items Each order contains an `items` array with all order lines. Items have a `class` field that indicates what type of line item it is. | Class | Description | |---|---| | `product` | A product the customer ordered | | `incentive` | A bonus or free item added by a promotion | | `surcharge` | An additional fee (handling, small order surcharge) | When displaying items in a portal, you will typically show all items but may want to visually distinguish incentives and surcharges from regular products. ### Item pricing Each item carries both unit and total pricing, with and without tax. | Field | Description | |---|---| | `price` | Unit price excluding tax | | `priceNet` | Unit price including tax | | `priceTotal` | Total price excluding tax (price x quantity) | | `priceTotalNet` | Total price including tax | | `tax` | Total tax amount for this item | | `taxPercentage` | Tax percentage applied | ### Discounts If an item has a discount applied, the `discount` field contains the discount amount per unit and `originalPrice` contains the price before the discount was applied. When `discount` is `null` or `0`, no discount was applied. ### Linking to product pages Use the `product` field on each item to get the product's `slugs` for building links to product detail pages. The `product` field returns `null` if the product has been deleted from the catalog since the order was placed. ## Order totals The `total` field provides the financial summary for the entire order. | Field | Type | Description | |---|---|---| | `gross` | `Float!` | Order total excluding tax | | `net` | `Float!` | Order total including tax | | `tax` | `Float!` | Total tax amount | | `discountType` | `OrderDiscountType!` | Discount type: `N` (none), `P` (percentage), `A` (absolute) | | `discountValue` | `Float!` | Discount value (percentage or absolute amount, depending on `discountType`) | | `taxPercentages` | `[OrderTotalTaxPercentage!]!` | Tax breakdown per rate | The `taxPercentages` array lists each tax rate applied to the order with its total. This is useful for invoices where you need to show the tax breakdown by rate. ```json { "taxPercentages": [ { "percentage": 21, "total": 741.06 } ] } ``` ## Addresses The `addresses` field on an order accepts a `type` argument to filter by address type: `delivery` or `invoice`. Use GraphQL aliases to fetch both delivery and invoice addresses in a single query. ```graphql deliveryAddress: addresses(type: delivery) { firstName middleName lastName company street number numberExtension postalCode city country } invoiceAddress: addresses(type: invoice) { firstName middleName lastName company street number numberExtension postalCode city country } ``` The `addresses` field returns an array because an order can technically have multiple addresses of the same type. In most cases there is one delivery address and one invoice address. > If you need all addresses without filtering by type, use the `orderAddresses` field instead. This returns `[OrderAddress!]!` with a `type` field on each address. ## Shipping and payment summary The `postageData` and `paymentData` fields contain the shipping and payment details set during checkout. ### postageData fields | Field | Type | Description | |---|---|---| | `method` | `String!` | Shipping method name | | `gross` | `Float!` | Shipping cost excluding tax | | `net` | `Float!` | Shipping cost including tax | | `tax` | `Float!` | Tax on shipping costs | | `taxPercentage` | `Float!` | Tax percentage for shipping | | `carrier` | `String` | Selected carrier name | | `requestDate` | `DateTime` | Requested delivery date | | `partialDeliveryAllowed` | `YesNo` | Whether partial delivery is allowed (`Y` or `N`) | ### paymentData fields | Field | Type | Description | |---|---|---| | `method` | `String!` | Payment method name | | `status` | `String` | Payment status | | `gross` | `Float!` | Transaction cost excluding tax | | `net` | `Float!` | Transaction cost including tax | | `tax` | `Float!` | Tax on transaction costs | | `taxPercentage` | `Float!` | Tax percentage for transaction costs | For full payment tracking with individual transactions, see [Payment integration](/frontend/domain-guides/cart-and-checkout/payment-integration). For shipment tracking with carrier details and tracking codes, see [Shipment tracking](/frontend/domain-guides/orders-and-shipments/shipment-tracking). ## Next steps - [Shipment tracking](/frontend/domain-guides/orders-and-shipments/shipment-tracking) for tracking shipments and displaying carrier information - [Reordering](/frontend/domain-guides/orders-and-shipments/reordering) to let customers reorder from previous orders - [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle) for order statuses and transitions - [Payment integration](/frontend/domain-guides/cart-and-checkout/payment-integration) for payment processing and transaction tracking --- ## Reordering URL: https://docs.propeller-commerce.com/frontend/domain-guides/orders-and-shipments/reordering # Reordering Let customers reorder items from a previous order by fetching the order items and adding them to a new cart. This is a frontend flow that combines an order query with cart mutations. Propeller does not have a dedicated reorder mutation, so your application handles the logic. Reordering is one of the most used features in B2B portals. Businesses regularly restock the same items (office supplies, raw materials, maintenance parts, safety equipment) and expect to do this with minimal clicks. A well-built reorder flow saves buyers significant time compared to searching for and adding each product individually. For cart creation and item management, see [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management). For listing and filtering orders, see [Order history](/frontend/domain-guides/orders-and-shipments/order-history). ## How reordering works A reorder flow has three steps: 1. Fetch the original order's items 2. Create a new cart (or use an existing one) 3. Add each product item to the cart The cart uses current pricing. Prices may have changed since the original order was placed. > Only order items with `class: product` should be reordered. Order items can also have classes like `incentive` or `surcharge`, which are system-generated and should not be added to the cart manually. ## Step 1: Fetch the order items Query the original order and include the fields needed for reordering: `productId`, `quantity` and `class`. Include `sku` and `name` for display purposes. ### Query ```graphql query OrderForReorder($orderId: Int!) { order(orderId: $orderId) { id items { id class productId sku name quantity notes } } } ``` ### Variables ```json { "orderId": 2075 } ``` ### Response ```json { "data": { "order": { "id": 2075, "items": [ { "id": 20151, "class": "product", "productId": 1187, "sku": "157957", "name": "Douglas Lariks fijn bezaagd ZWART", "quantity": 4, "notes": "" }, { "id": 20152, "class": "product", "productId": 1139, "sku": "VFS-200", "name": "Ventilatie Filterset Fresh Air", "quantity": 2, "notes": "" }, { "id": 20153, "class": "product", "productId": 204, "sku": "695123-3", "name": "Power cord 2x1.50 4.0mtr", "quantity": 3, "notes": "" } ] } } } ``` Filter the items in your frontend to include only those where `class` is `product`. Discard items with `class` values like `incentive` or `surcharge`. ## Step 2: Create a new cart If the customer does not already have an active cart, create one using `cartStart`. ### Mutation ```graphql mutation CartStart { cartStart { cartId } } ``` ### Response ```json { "data": { "cartStart": { "cartId": "019c9f63-c127-70ca-8bac-ec3dd43c8bfd" } } } ``` If the customer already has an active cart, you can add the reorder items to it directly. See [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) for details on creating and managing carts. ## Step 3: Add items to the cart For each product item from the order, call `cartAddItem` with the `productId` and `quantity`. ### Mutation ```graphql mutation CartAddItem($id: String!, $input: CartAddItemInput!) { cartAddItem(id: $id, input: $input) { cartId items { itemId productId quantity price priceNet totalPrice totalPriceNet product { productId names { value language } sku } } total { subTotal subTotalNet totalGross totalNet } } } ``` ### Variables ```json { "id": "019c9f63-c127-70ca-8bac-ec3dd43c8bfd", "input": { "productId": 1187, "quantity": 4 } } ``` ### Response ```json { "data": { "cartAddItem": { "cartId": "019c9f63-c127-70ca-8bac-ec3dd43c8bfd", "items": [ { "itemId": "019c9f63-da2c-7006-8aeb-f47c2b86d861", "productId": 1187, "quantity": 4, "price": 24.54, "priceNet": 29.69, "totalPrice": 98.16, "totalPriceNet": 118.77, "product": { "productId": 1187, "names": [ { "value": "Douglas Lariks fijn bezaagd ZWART", "language": "NL" } ], "sku": "157957" } } ], "total": { "subTotal": 98.16, "subTotalNet": 118.77, "totalGross": 153.16, "totalNet": 185.32 } } } } ``` Repeat the `cartAddItem` call for each product item from the order. Each call returns the updated cart with all items and totals. For orders with many items, `cartItemBulk` (documented in [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management#bulk-item-operations)) is more efficient because it adds or updates all items in a single API call instead of sequential calls per item. ### CartAddItemInput fields | Field | Type | Required | Description | |---|---|---|---| | `productId` | `Int!` | Yes | The product to add | | `quantity` | `Int` | No | Quantity to add (default: `1`) | | `notes` | `String` | No | Customer notes for this item | | `clusterId` | `Int` | No | Cluster ID if the product belongs to a cluster | | `price` | `Float` | No | Override price per unit (for external pricing only) | | `childItems` | `[CartChildItemInput!]` | No | Child items for configurable cluster products | ## Handling unavailable products Products from the original order may no longer be available. Before adding an item, you can check the product's status by including the `product` field in the order query: ```graphql query OrderForReorder($orderId: Int!) { order(orderId: $orderId) { id items { id class productId quantity product { productId names { value language } sku status orderable } } } } ``` Check the `product.status` and `product.orderable` fields before attempting to add the item to the cart. If `status` is not `A` (available) or `orderable` is `N`, the product cannot be ordered and you should inform the customer. | Status | Meaning | |---|---| | `A` | Available | | `N` | Not available | | `P` | Phase out | | `S` | Presale | | `R` | Restricted | | `T` | Out of stock | > The `product` field on an order item can return `null` if the product has been deleted from the catalog since the order was placed. Handle this case in your frontend by showing a message that the product is no longer available. ## Pricing differences The cart uses current pricing, not the prices from the original order. If you want to show the customer that prices have changed, you can compare the order item's `price` field (the price at the time of the original order) with the cart item's `price` field (the current price). ```graphql query OrderWithPrices($orderId: Int!) { order(orderId: $orderId) { items { id class productId name quantity price priceNet } } } ``` ```json { "data": { "order": { "items": [ { "id": 26987, "class": "product", "productId": 44, "name": "EcoSpot Recessed Downlight", "quantity": 10, "price": 309.14, "priceNet": 374.06 }, { "id": 26992, "class": "product", "productId": 12, "name": "Eclipse Elite LED Desk Lamp", "quantity": 5, "price": 85.49, "priceNet": 103.44 } ] } } } ``` The order item `price` is the unit price excluding tax at the time the order was placed. The order item `priceNet` is the unit price including tax. Compare these with the cart item's `price` and `priceNet` after adding to identify any changes. ## Cluster products If the original order contained products that belong to a cluster (variant products), include the `clusterId` when adding them to the cart. You can retrieve the cluster ID from the order item's product: ```graphql query OrderForReorder($orderId: Int!) { order(orderId: $orderId) { items { id class productId quantity product { productId cluster { clusterId } } } } } ``` ```json { "data": { "order": { "items": [ { "id": 25978, "class": "product", "productId": 45, "quantity": 1, "product": { "productId": 45, "cluster": null } }, { "id": 25981, "class": "product", "productId": 56, "quantity": 1, "product": { "productId": 56, "cluster": { "clusterId": 1 } } } ] } } } ``` When `product.cluster` is not null, pass the `clusterId` to `cartAddItem`: ```json { "id": "019c9f63-c127-70ca-8bac-ec3dd43c8bfd", "input": { "productId": 1187, "quantity": 4, "clusterId": 52 } } ``` ## Next steps - [Order history](/frontend/domain-guides/orders-and-shipments/order-history) for listing and filtering orders - [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) for creating carts and managing items - [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow) for completing the checkout after reordering --- ## Shipment Tracking URL: https://docs.propeller-commerce.com/frontend/domain-guides/orders-and-shipments/shipment-tracking # Shipment tracking Display shipment information, delivery statuses and tracking codes for orders. After an order is confirmed, shipments track the physical fulfillment. Each shipment contains a subset of the order's items and can carry track and trace codes for carrier tracking. An order can have multiple shipments for partial deliveries. For order statuses and the overall order lifecycle, see [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle). For listing and filtering orders, see [Order history](/frontend/domain-guides/orders-and-shipments/order-history). ## Shipment statuses The `ShipmentStatus` enum tracks the delivery state of a shipment. | Status | Description | |---|---| | `CREATED` | Shipment record created | | `PROCESSING` | Shipment is being prepared | | `IN_TRANSIT` | Picked up by the carrier | | `OUT_FOR_DELIVERY` | Out for final delivery | | `DELIVERED` | Successfully delivered | | `PARTIALLY_DELIVERED` | Some items in the shipment delivered | | `FAILED_DELIVERY` | Delivery attempt failed | | `CANCELED` | Shipment canceled | | `EXCEPTION` | An exception occurred during delivery | ## Fetching shipments for an order There are two ways to fetch shipments: nested inside an order query or via the standalone `shipments` query. ### Via the order query Include the `shipments` field when querying an order to get all shipments inline. #### Query ```graphql query OrderWithShipments($orderId: Int!) { order(orderId: $orderId) { id status shipments { id status expectedDeliveryAt createdAt items { id name sku quantity orderItemId } trackAndTraces { id code carrierId carrier { id name trackAndTraceURL } } } } } ``` #### Variables ```json { "orderId": 2075 } ``` #### Response ```json { "data": { "order": { "id": 2075, "status": "CONFIRMED", "shipments": [ { "id": "65ad4404-88a6-4f97-9325-34a3a35f1dfc", "status": "CREATED", "expectedDeliveryAt": "2025-12-05T10:00:00.000Z", "createdAt": "2025-11-03T14:34:48.828Z", "items": [ { "id": "bda8e812-4238-49b4-aafc-6a153e265d59", "name": "Douglas Lariks fijn bezaagd ZWART", "sku": "157957", "quantity": 4, "orderItemId": 20151 }, { "id": "781dd85a-6ba4-4bd0-9399-6d3bb1632b1a", "name": "Ventilatie Filterset Fresh Air", "sku": "VFS-200", "quantity": 2, "orderItemId": 20152 } ], "trackAndTraces": [ { "id": "2d1860e6-e58c-41b8-b9d7-689b355b0c52", "code": "3STEST1234567890", "carrierId": 1, "carrier": { "id": 1, "name": "PostNL", "trackAndTraceURL": "https://postnl.nl/tracktrace/?B={code}" } } ] } ] } } } ``` > Shipment IDs are UUIDs (strings), not integers. The `id` argument on the `shipment` query expects a `String!`. ### Via the shipments query The standalone `shipments` query returns a paginated list and supports filtering by order IDs, statuses and date ranges. Use this when you need to list shipments across multiple orders or apply filters. #### Query ```graphql query ShipmentsByOrder($input: ShipmentSearchInput) { shipments(input: $input) { items { id status orderId expectedDeliveryAt createdAt items { id name sku quantity orderItemId } trackAndTraces { id code carrierId carrier { id name trackAndTraceURL } } } itemsFound page pages offset start end } } ``` #### Variables ```json { "input": { "orderIds": [2075], "page": 1, "offset": 12 } } ``` #### Response ```json { "data": { "shipments": { "items": [ { "id": "65ad4404-88a6-4f97-9325-34a3a35f1dfc", "status": "CREATED", "orderId": 2075, "expectedDeliveryAt": "2025-12-05T10:00:00.000Z", "createdAt": "2025-11-03T14:34:48.828Z", "items": [ { "id": "bda8e812-4238-49b4-aafc-6a153e265d59", "name": "Douglas Lariks fijn bezaagd ZWART", "sku": "157957", "quantity": 4, "orderItemId": 20151 }, { "id": "781dd85a-6ba4-4bd0-9399-6d3bb1632b1a", "name": "Ventilatie Filterset Fresh Air", "sku": "VFS-200", "quantity": 2, "orderItemId": 20152 } ], "trackAndTraces": [ { "id": "2d1860e6-e58c-41b8-b9d7-689b355b0c52", "code": "3STEST1234567890", "carrierId": 1, "carrier": { "id": 1, "name": "PostNL", "trackAndTraceURL": "https://postnl.nl/tracktrace/?B={code}" } } ] } ], "itemsFound": 1, "page": 1, "pages": 1, "offset": 12, "start": 1, "end": 1 } } } ``` ## Fetching a single shipment Use the `shipment` query to fetch a specific shipment by its ID. This is useful for a shipment detail page where you want the full shipment data including item details from the original order. ### Query ```graphql query Shipment($id: String!) { shipment(id: $id) { id status orderId expectedDeliveryAt createdAt lastModifiedAt items { id name sku quantity orderItemId shipmentId orderItem { id name sku quantity price priceNet productId } } trackAndTraces { id code carrierId shipmentId createdAt carrier { id name type trackAndTraceURL logo } } } } ``` ### Variables ```json { "id": "65ad4404-88a6-4f97-9325-34a3a35f1dfc" } ``` ### Response ```json { "data": { "shipment": { "id": "65ad4404-88a6-4f97-9325-34a3a35f1dfc", "status": "CREATED", "orderId": 2075, "expectedDeliveryAt": "2025-12-05T10:00:00.000Z", "createdAt": "2025-11-03T14:34:48.828Z", "lastModifiedAt": "2025-11-03T14:34:48.828Z", "items": [ { "id": "bda8e812-4238-49b4-aafc-6a153e265d59", "name": "Douglas Lariks fijn bezaagd ZWART", "sku": "157957", "quantity": 4, "orderItemId": 20151, "shipmentId": "65ad4404-88a6-4f97-9325-34a3a35f1dfc", "orderItem": { "id": 20151, "name": "2,1X19,5X300cm Douglas / Lariks fijn bezaagd ZWART (Op=Op)", "sku": "157957", "quantity": 4, "price": 23.31, "priceNet": 28.21, "productId": 1187 } }, { "id": "781dd85a-6ba4-4bd0-9399-6d3bb1632b1a", "name": "Ventilatie Filterset Fresh Air", "sku": "VFS-200", "quantity": 2, "orderItemId": 20152, "shipmentId": "65ad4404-88a6-4f97-9325-34a3a35f1dfc", "orderItem": { "id": 20152, "name": "Ventilatie Filterset Fresh Air: 1x ePM10 (M5), 1x Coarse (G4)", "sku": "VFS-200", "quantity": 2, "price": 21.80, "priceNet": 26.38, "productId": 1139 } } ], "trackAndTraces": [ { "id": "2d1860e6-e58c-41b8-b9d7-689b355b0c52", "code": "3STEST1234567890", "carrierId": 1, "shipmentId": "65ad4404-88a6-4f97-9325-34a3a35f1dfc", "createdAt": "2025-11-03T14:34:48.828Z", "carrier": { "id": 1, "name": "PostNL", "type": "DELIVERY", "trackAndTraceURL": "https://postnl.nl/tracktrace/?B={code}", "logo": null } } ] } } } ``` ### Shipment fields | Field | Type | Description | |---|---|---| | `id` | `String!` | UUID identifier | | `orderId` | `Int!` | The order this shipment belongs to | | `status` | `ShipmentStatus` | Current delivery status | | `expectedDeliveryAt` | `DateTime` | Expected delivery date and time | | `createdAt` | `DateTime!` | When the shipment was created | | `lastModifiedAt` | `DateTime!` | When the shipment was last updated | | `items` | `[ShipmentItem!]` | Products included in this shipment | | `trackAndTraces` | `[TrackAndTrace!]` | Tracking codes for this shipment | ## Shipment items Each shipment item represents a product from the order that is included in the shipment. The `orderItemId` links back to the original order line and the `orderItem` field returns the full order item with pricing and product details. | Field | Type | Description | |---|---|---| | `id` | `String!` | UUID identifier | | `name` | `String` | Item name (as recorded on the shipment) | | `sku` | `String` | Product SKU | | `quantity` | `Int` | Quantity shipped | | `orderItemId` | `Int` | Reference to the original order item | | `shipmentId` | `String!` | The shipment this item belongs to | | `orderItem` | `OrderItem!` | Full order line details | The `orderItem` field gives you access to the original order line including `name`, `sku`, `quantity` (the ordered quantity), `price` (unit price excluding tax), `priceNet` (unit price including tax) and `productId`. This is useful when the shipment item's `name` or `sku` differs from the order line or when you need pricing information. > The `quantity` on a shipment item can differ from the `quantity` on the order item. For example, if a customer ordered 10 units and only 6 have shipped, the shipment item quantity is 6 while the order item quantity remains 10. ## Track and trace codes Each shipment can have one or more track and trace codes. The `carrier` field on each track and trace returns the carrier's details including the `trackAndTraceURL` for building tracking links. | Field | Type | Description | |---|---|---| | `id` | `String!` | UUID identifier | | `code` | `String!` | Tracking code from the carrier | | `carrierId` | `Int` | Carrier identifier | | `shipmentId` | `String!` | The shipment this code belongs to | | `carrier` | `Carrier` | Carrier details | ### Carrier fields | Field | Type | Description | |---|---|---| | `id` | `Int!` | Carrier identifier | | `name` | `String!` | Carrier name (e.g. `PostNL`, `DHL`, `DPD`) | | `type` | `CarrierType!` | `DELIVERY` or `PICKUP` | | `trackAndTraceURL` | `String` | URL template for tracking links | | `logo` | `String` | Carrier logo URL | ### Building tracking links When a carrier has a `trackAndTraceURL`, you can use it to build a tracking link for the customer. The URL typically contains a placeholder for the tracking code. Replace the placeholder with the `code` value from the track and trace record. For example, if the carrier's `trackAndTraceURL` is `https://postnl.nl/tracktrace/?B={code}` and the tracking code is `3STEST1234567890`, the tracking link would be: ``` https://postnl.nl/tracktrace/?B=3STEST1234567890 ``` > The `trackAndTraceURL` can be `null` if no URL template has been configured for the carrier. In that case, display the tracking code as plain text so the customer can look it up on the carrier's website manually. ## Filtering shipments The `shipments` query accepts a `ShipmentSearchInput` with several filter options. | Field | Type | Description | |---|---|---| | `page` | `Int!` | Page number (default: `1`) | | `offset` | `Int!` | Items per page (default: `12`) | | `orderIds` | `[Int!]` | Filter by one or more order IDs | | `ids` | `[String!]` | Filter by one or more shipment IDs | | `statuses` | `[ShipmentStatus!]` | Filter by one or more shipment statuses | | `createdAt` | `DateSearchInput` | Filter by creation date range | | `lastModifiedAt` | `DateSearchInput` | Filter by last modified date range | | `expectedDeliveryAt` | `DateSearchInput` | Filter by expected delivery date range | | `sortInputs` | `[ShipmentSortInput!]` | Sort order | ### Example: filter by status ```graphql query InTransitShipments($input: ShipmentSearchInput) { shipments(input: $input) { items { id status orderId expectedDeliveryAt trackAndTraces { code carrier { name trackAndTraceURL } } } itemsFound page pages } } ``` ```json { "input": { "statuses": ["IN_TRANSIT", "OUT_FOR_DELIVERY"], "page": 1, "offset": 20 } } ``` This returns all shipments that are currently in transit or out for delivery across all orders. ## Partial deliveries An order can have multiple shipments when items are delivered in separate batches. Each shipment contains only the items and quantities included in that delivery. To determine which items from an order have been shipped, compare the shipment items against the order items: ```graphql query OrderWithShipmentDetails($orderId: Int!) { order(orderId: $orderId) { id status items { id name sku quantity } shipments { id status items { orderItemId quantity } } } } ``` ```json { "orderId": 2075 } ``` ```json { "data": { "order": { "id": 2075, "status": "CONFIRMED", "items": [ { "id": 20151, "name": "Douglas Lariks fijn bezaagd ZWART", "sku": "157957", "quantity": 4 }, { "id": 20152, "name": "Ventilatie Filterset Fresh Air", "sku": "VFS-200", "quantity": 2 }, { "id": 20153, "name": "Power cord 2x1.50 4.0mtr", "sku": "695123-3", "quantity": 3 }, { "id": 20154, "name": "Printerstandaard voor ergonomische plaatsing", "sku": "STD-01", "quantity": 3 }, { "id": 20155, "name": "Accu Cirkelzaag 18V Compact", "sku": "ACZ-18V", "quantity": 4 } ], "shipments": [ { "id": "65ad4404-88a6-4f97-9325-34a3a35f1dfc", "status": "CREATED", "items": [ { "orderItemId": 20151, "quantity": 4 }, { "orderItemId": 20152, "quantity": 2 } ] } ] } } } ``` In this example, order items 20151 and 20152 have been shipped in full. Order items 20153, 20154 and 20155 have not been shipped yet. You can use this comparison to show customers which items are still pending. ## Next steps - [Order history](/frontend/domain-guides/orders-and-shipments/order-history) for listing and filtering orders - [Reordering](/frontend/domain-guides/orders-and-shipments/reordering) for letting customers reorder from previous orders - [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle) for order statuses and transitions --- ## Customer-specific Pricing URL: https://docs.propeller-commerce.com/frontend/domain-guides/pricing-and-discounts/customer-specific-pricing # Customer-specific pricing Propeller supports pricing that varies by customer, company or user group. This page covers how customer-specific pricing works and how to handle it in your frontend. ## How customer-specific pricing works When a user is authenticated, the GraphQL API automatically returns prices based on **price sheets** assigned to their company or customer group. All of this is handled server-side. When you query a product with an authenticated session, the `price` field already reflects the customer's specific pricing. You don't need to apply any discounts in your frontend code. ## Price sheets Price sheets are the core mechanism for customer-specific pricing. A price sheet contains discount items that target specific products, categories or product groups. In B2B, price sheets typically represent negotiated pricing agreements between the merchant and the buyer's company. These agreements are often tied to annual contracts and may cover specific product categories, volume commitments or the entire catalog. When a contact logs in and belongs to a company with an assigned price sheet, all product prices automatically reflect the agreed rates. Each price sheet has: - **Priority** — a number that determines precedence. Lower numbers take priority over higher numbers. - **Discount items** — individual pricing rules within the sheet. Each item specifies a discount type, value, optional volume thresholds and optional date-range validity. ### Discount calculation types Each discount item uses one of three calculation methods: | Type | Description | Example | |---|---|---| | `COST_PRICE_PLUS` | Cost price plus a percentage margin | Cost €40 + 25% = €50 | | `LIST_PRICE_MIN` | List price minus a percentage | List €100 − 20% = €80 | | `NET_PRICE` | Fixed amount (direct price) | Price = €75 | ### How overlapping discounts resolve When multiple price sheet items apply to the same product: 1. The item from the price sheet with the **lowest priority number** wins 2. If priority numbers are equal, the item that produces the **lowest price** is selected 3. Orders outside configured volume ranges fall back to the **list price** ## Displaying customer prices For anonymous users, the product price reflects the default pricing. For authenticated users, it reflects their price sheet pricing: ```graphql query GetProductPrice($productId: Int, $taxZone: String) { product(productId: $productId) { price(input: { taxZone: $taxZone }) { gross net type discountType discount { value quantityFrom } } priceData { list } } } ``` If the customer has a price sheet, `price.gross` and `price.net` will reflect the price sheet price. The `price.type` field will return `PRICESHEET` to indicate customer-specific pricing is active. Compare `price.gross` against `priceData.list` to show savings. When the list price is higher than the customer price, display the difference as a discount. In B2B scenarios, consider showing a "Log in for your price" prompt for anonymous users. Some B2B storefronts hide prices entirely for anonymous visitors and only show them after login. This is common in industries where pricing is confidential and varies significantly between customers. The `price.type` field helps you decide what label to show: "Your price" when the type is `PRICESHEET` (contract pricing) or "List price" when the type is `DEFAULT`. ## Action codes (discount codes) Action codes are promotional codes that apply discounts at the cart level. Apply them with `cartAddActionCode`: ```graphql mutation { cartAddActionCode( id: "018dcc9a-f965-7434-8fad-369aa9a8c276" input: { actionCode: "PROMO15" } ) { actionCode total { totalGross totalNet discount discountPercentage } } } ``` Remove a code with `cartRemoveActionCode`: ```graphql mutation { cartRemoveActionCode( id: "018dcc9a-f965-7434-8fad-369aa9a8c276" input: { actionCode: "PROMO15" } ) { actionCode total { totalGross totalNet } } } ``` The mutation returns an error if the action code cannot be applied. ## Custom price overrides in the cart When integrating with an external pricing system, you can override the calculated price per unit when adding an item to the cart: ```graphql mutation { cartAddItem( id: "018dcc9a-f965-7434-8fad-369aa9a8c276" input: { productId: 67890 quantity: 10 price: 42.50 } ) { items { itemId productId quantity price priceNet } total { totalGross totalNet } } } ``` The `price` field in the input overrides the calculated price for this item. This should only be used when integrating with external pricing systems. ## Next steps - [Understanding pricing layers](/frontend/domain-guides/pricing-and-discounts/understanding-pricing-layers) — price types, tax zones and surcharges - [Tiered and volume pricing](/frontend/domain-guides/pricing-and-discounts/tiered-and-volume-pricing) — bulk discounts based on quantity - [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) — how prices and discounts flow into the cart --- ## Tiered and Volume Pricing URL: https://docs.propeller-commerce.com/frontend/domain-guides/pricing-and-discounts/tiered-and-volume-pricing # Tiered and volume pricing Propeller supports bulk pricing — discounted prices that apply when a customer orders larger quantities. This page covers how to query and display tiered pricing. ## How bulk pricing works Bulk prices are quantity-based price tiers configured on a product. When a customer adds a product to the cart with a quantity that falls within a bulk price tier, the lower price is automatically applied. For example, a product might have: | Quantity | Price (excl. tax) | |---|---| | 1–9 | €10.00 | | 10–49 | €8.50 | | 50+ | €7.00 | Propeller calculates the correct price based on the cart quantity. You don't need to apply the tier logic in your frontend. Orders outside configured volume tiers fall back to the list price. ## Discount calculation types The `priceData.bulkPriceDiscountType` indicates how bulk discounts are calculated: | Type | Description | Example | |---|---|---| | `COST_PRICE_PLUS` | Cost price plus a percentage margin | Cost €40 + 25% margin = €50 | | `LIST_PRICE_MIN` | List price minus a percentage | List €100 − 20% = €80 | | `NET_PRICE` | Fixed amount (direct price per unit) | Price = €75 | ## Querying bulk prices Use the `bulkPrices` field on a product to retrieve the available price tiers: ```graphql query GetBulkPrices($productId: Int, $taxZone: String) { product(productId: $productId) { price(input: { taxZone: $taxZone }) { gross net type } priceData { list bulkPriceDiscountType } bulkPrices(input: { taxZone: $taxZone }) { gross net discount { quantityFrom validFrom validTo } } } } ``` **Response:** ```json { "data": { "product": { "price": { "gross": 24.25, "net": 29.34, "type": "DEFAULT" }, "priceData": { "list": 24.25, "bulkPriceDiscountType": "NET_PRICE" }, "bulkPrices": [ { "gross": 23.43, "net": 28.35, "discount": { "quantityFrom": 8, "validFrom": null, "validTo": null } }, { "gross": 22.35, "net": 27.04, "discount": { "quantityFrom": 12, "validFrom": null, "validTo": null } }, { "gross": 21.95, "net": 26.56, "discount": { "quantityFrom": 16, "validFrom": null, "validTo": null } }, { "gross": 20.34, "net": 24.61, "discount": { "quantityFrom": 20, "validFrom": null, "validTo": null } } ] } } } ``` ### Bulk price fields | Field | Description | |---|---| | `gross` | Tier price excluding tax | | `net` | Tier price including tax | | `discount.quantityFrom` | Minimum quantity for this tier to apply | | `discount.validFrom` / `discount.validTo` | Date range when this tier is valid (null means no restriction) | The `price.type` field returns `BULK_SALES_PRICE` or `BULK_COST_PRICE` when a bulk tier is active. ## Volume-based cost prices Separately from bulk sales pricing, Propeller supports cost prices that vary by quantity. These are configured on the product's `priceData.costPrices` field: ```graphql priceData { costPrices { quantityFrom value } } ``` Each cost price has a `quantityFrom` threshold and a `value` per unit. Volume-based cost prices are used for margin calculations when the discount type is `COST_PRICE_PLUS`. ## Bulk pricing in the cart When items are added to the cart, Propeller automatically applies the correct bulk price tier based on the total quantity. The cart response reflects the tiered price: ```graphql query { cart(id: "018dcc9a-f965-7434-8fad-369aa9a8c276") { items { productId quantity price priceNet totalPrice totalPriceNet } } } ``` ## Next steps - [Understanding pricing layers](/frontend/domain-guides/pricing-and-discounts/understanding-pricing-layers) — price types, tax zones and surcharges - [Customer-specific pricing](/frontend/domain-guides/pricing-and-discounts/customer-specific-pricing) — price sheets, action codes and overrides - [Querying products](/frontend/domain-guides/products-and-catalog/querying-products) — include pricing in product listing queries --- ## Understanding Pricing Layers URL: https://docs.propeller-commerce.com/frontend/domain-guides/pricing-and-discounts/understanding-pricing-layers # Understanding pricing layers Propeller has a multi-layered pricing model. Understanding how prices are structured helps you display the right data in product listings, detail pages and carts. ## Price types Every product has several price fields: | Field | Description | |---|---| | `gross` | Price **excluding** tax | | `net` | Price **including** tax | | `list` | Default sales price, used as the baseline for discount calculations | | `store` | Price at which the product is sold at physical counter locations (common in building supplies, electrical wholesale and industrial parts distribution where the online price and counter price may differ) | | `suggested` | Manufacturer's or supplier's recommended retail price (RRP) | | `cost` | Purchase/cost price (deprecated in favor of `costPrices`) | | `costPrices` | Quantity-based cost prices with a `quantityFrom` threshold and `value` per unit | The `gross` and `net` values are the ones customers actually pay. The other price types are reference prices useful for showing savings or comparing prices. Propeller uses `gross` and `net` differently from standard accounting. In most accounting contexts, "gross" means including tax and "net" means excluding tax. Propeller uses it the other way around: `gross` is the price **excluding** tax and `net` is the price **including** tax. The `price` field returns the calculated price for the current context (user, tax zone, quantity). The `priceData` field returns the raw price configuration. ## Price source types The `price.type` field tells you where the price came from: | Type | Description | |---|---| | `DEFAULT` | Standard product price (list price) | | `PRICESHEET` | Customer-specific price from a price sheet | | `BULK_SALES_PRICE` | Bulk/volume discount on the sales price | | `BULK_COST_PRICE` | Bulk/volume discount based on cost price | Use this field to show context in your frontend, for example displaying a "Your price" label when `type` is `PRICESHEET`. ## Discount calculation types The `price.discountType` and `priceData.bulkPriceDiscountType` fields indicate how discounts are calculated. Propeller supports three calculation methods: | Type | Description | Example | |---|---|---| | `COST_PRICE_PLUS` | Cost price plus a percentage margin | Cost €40 + 25% = €50 | | `LIST_PRICE_MIN` | List price minus a percentage | List €100 − 20% = €80 | | `NET_PRICE` | Fixed amount (direct price) | Price = €75 | ## Tax zones Prices vary by tax zone. Pass a `taxZone` to the price query to get the correct tax calculation for the customer's region. The `priceData.defaultTaxCode` indicates the product's default tax rate (`H` = high, `L` = low, `N` = none). ## How prices are resolved When a product price is requested, Propeller evaluates pricing rules in this order: 1. **Customer-specific pricing (price sheets) always takes precedence** over product-level pricing when the user is authenticated and has applicable price sheets, even if product-level pricing would produce a lower price 2. Within price sheets, **lower priority numbers** take precedence 3. When multiple price sheets have the same priority, the **lowest price** is selected 4. When multiple discounts overlap in date range, the **lowest price** is selected 5. If no price sheet applies, **product-level bulk pricing** is evaluated 6. Orders outside configured volume ranges fall back to the **list price** 7. When no pricing rules apply, the product's **list price** is used Price sheets take precedence by design because customer-specific pricing in B2B represents a negotiated contract. A contract price governs the relationship even when a temporary product-level promotion would produce a lower price. This resolution happens server-side. Your frontend always receives the final calculated price. ## Price discounts The `price.discount` field shows any discounts that apply to the product for the current user: | Field | Description | |---|---| | `value` | Discount amount or percentage | | `quantityFrom` | Minimum quantity to trigger the discount | | `validFrom` / `validTo` | Discount validity period | Discounts can come from price sheets or bulk pricing. See [Customer-specific pricing](/frontend/domain-guides/pricing-and-discounts/customer-specific-pricing) for details. ## Surcharges Surcharges are additional fees on top of the base price (handling fees, environmental charges, etc.). | Field | Description | |---|---| | `type` | Surcharge type: `FlatFee` (fixed amount) or `Percentage` | | `value` | The surcharge amount | | `enabled` | Whether the surcharge is currently active | | `validFrom` / `validTo` | Time window when the surcharge applies | ## Cart pricing When products are added to a cart, Propeller recalculates all prices server-side, applying: - Customer-specific pricing (price sheets) - Quantity-based discounts (bulk pricing) - Action codes (discount codes) - Shipping costs - Transaction fees - Tax calculations You don't need to calculate anything in your frontend. All totals are returned by the cart query. See [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) for details on cart totals. ## Next steps - [Customer-specific pricing](/frontend/domain-guides/pricing-and-discounts/customer-specific-pricing) — price sheets, action codes and custom price overrides - [Tiered and volume pricing](/frontend/domain-guides/pricing-and-discounts/tiered-and-volume-pricing) — bulk pricing and quantity discounts - [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) — how prices flow into the cart --- ## Categories and Navigation URL: https://docs.propeller-commerce.com/frontend/domain-guides/products-and-catalog/categories-and-navigation # Categories and navigation Query the category tree, build navigation menus, display breadcrumbs and fetch category detail pages using Propeller's GraphQL API. ## Fetching categories Use the `categories` query to retrieve the category tree. Categories have a parent-child hierarchy that you can use to build nested navigation. ```graphql query GetCategories($language: String) { categories { items { ... on Category { categoryId name(language: $language) { value } parent { categoryId name(language: $language) { value } } categoryPath { categoryId name(language: $language) { value } } } } itemsFound page pages } } ``` The response includes: - `parent` — the direct parent category (null for top-level categories) - `categoryPath` — the full hierarchy from root to the current category, useful for building breadcrumbs ### Filtering categories The `categories` query accepts a `CategorySearchInput` to narrow results: | Filter | Description | |---|---| | `categoryId` | Find categories by one or more IDs | | `parentCategoryId` | Find child categories of a specific parent | | `name` | Find categories by name (text search) | | `slug` | Find categories by URL slug | | `language` | Language to use when searching by name or slug | | `hidden` | Filter by visibility (`Y` or `N`) | | `sortField` | Sort results by `name`, `dateCreated` or `dateChanged` | | `sortOrder` | Sort direction: `ASC` (default) or `DESC` | | `page` / `offset` | Pagination controls | To fetch only top-level categories, filter by the root parent ID. The root category ID varies per Propeller installation: ```graphql query GetTopLevelCategories($language: String) { categories(filter: { parentCategoryId: [1] }) { items { ... on Category { categoryId name(language: $language) { value } } } } } ``` To fetch subcategories of a specific category: ```graphql query GetSubcategories($parentId: [Int!], $language: String) { categories(filter: { parentCategoryId: $parentId }) { items { ... on Category { categoryId name(language: $language) { value } } } } } ``` ## Category detail page Fetch detailed information for a single category by slug: ```graphql query GetCategoryDetails($slug: String, $language: String) { category(slug: $slug) { categoryId name { language value } description { language value } shortDescription { language value } slug(language: $language) { value } parent { categoryId name(language: $language) { value } } categoryPath { categoryId name(language: $language) { value } slug(language: $language) { value } } } } ``` Use `categoryPath` to build a breadcrumb from the root category down to the current one. ## Building a breadcrumb The `categoryPath` field returns the full hierarchy as an ordered array. Each entry contains the category name, slug and ID, which you can map to your breadcrumb component. This also works on the `product` query — products have a `categoryPath` field that returns the hierarchy of their assigned category. ## Products within a category To display products from a specific category, use the `products` query with a `categoryId` filter: ```graphql query GetCategoryProducts($categoryId: Int, $language: String) { products(input: { categoryId: $categoryId }) { items { ... on Product { productId names(language: $language) { value } price { gross net } } ... on Cluster { names(language: $language) { value } defaultProduct { productId price { gross net } } } } itemsFound page pages } } ``` This returns both products and clusters within the category. Combine with pagination, filters and sorting as described in [Querying products](/frontend/domain-guides/products-and-catalog/querying-products). ## Next steps - [Understanding products and categories](/frontend/domain-guides/products-and-catalog/understanding-products-and-categories) — how clusters, products and attributes work - [Querying products](/frontend/domain-guides/products-and-catalog/querying-products) — filtering, pagination and search - [Media and assets](/frontend/domain-guides/products-and-catalog/media-and-assets) — product images, videos and documents --- ## Media and Assets URL: https://docs.propeller-commerce.com/frontend/domain-guides/products-and-catalog/media-and-assets # Media and assets Fetch and display product images, videos and documents using Propeller's GraphQL API. Images support server-side transformations so you can request exactly the sizes and formats your UI needs. Propeller supports three media types: | Type | Contains | Key field | |---|---|---| | Images | Product photos | `images` with `originalUrl`, `imageVariants` for transformed URLs | | Videos | YouTube, Vimeo or hosted video URLs | `videos` with `uri` per language | | Documents | PDFs, spec sheets, manuals | `documents` with `originalUrl` per language | All media types support localized `alt` text, `description` and `tags`. The `priority` field controls display order. ## Querying product images Use the `media` field on a product to fetch its images. Request `imageVariants` with named transformations to get URLs at the exact size you need. ```graphql query GetProductImages($productId: Int!) { product(productId: $productId) { ... on Product { media { images(search: { sort: ASC, page: 1, offset: 12 }) { items { id alt { language value } description { language value } priority images { originalUrl mimeType } imageVariants( input: { transformations: [ { name: "thumbnail" transformation: { width: 120 height: 120 fit: BOUNDS bgColor: "transparent" canvas: { width: 120, height: 120 } } } { name: "large" transformation: { width: 800 height: 800 fit: BOUNDS } } ] } ) { name url } } itemsFound page pages } } } } } ``` **Expected response:** ```json { "data": { "product": { "media": { "images": { "items": [ { "id": "cb853191-b9b4-4a0e-9240-f2168f1e0ef7", "alt": [ { "language": "NL", "value": "Ergonomic desk chair front view" } ], "description": [ { "language": "NL", "value": "Ergonomic desk chair front view" } ], "priority": 1, "images": [ { "originalUrl": "https://media.helice.cloud/example/images/en/cb853191-chair-front.jpg", "mimeType": "image/jpeg" } ], "imageVariants": [ { "name": "thumbnail", "url": "https://media.helice.cloud/example/images/en/cb853191-chair-front.jpg?bg-color=transparent&canvas=120,120&fit=bounds&height=120&width=120" }, { "name": "large", "url": "https://media.helice.cloud/example/images/en/cb853191-chair-front.jpg?fit=bounds&height=800&width=800" } ] }, { "id": "8c77dcfc-737e-4345-921e-25570aae9232", "alt": [ { "language": "NL", "value": "Ergonomic desk chair side view" } ], "description": [ { "language": "NL", "value": "Ergonomic desk chair side view" } ], "priority": 2, "images": [ { "originalUrl": "https://media.helice.cloud/example/images/en/8c77dcfc-chair-side.jpg", "mimeType": "image/jpeg" } ], "imageVariants": [ { "name": "thumbnail", "url": "https://media.helice.cloud/example/images/en/8c77dcfc-chair-side.jpg?bg-color=transparent&canvas=120,120&fit=bounds&height=120&width=120" }, { "name": "large", "url": "https://media.helice.cloud/example/images/en/8c77dcfc-chair-side.jpg?fit=bounds&height=800&width=800" } ] } ], "itemsFound": 2, "page": 1, "pages": 1 } } } } } ``` Each image has two URL sources: - `images` contains the original uploaded file with `originalUrl` and `mimeType`. - `imageVariants` returns transformed URLs based on the transformations you define in the query. Each variant is identified by the `name` you assign. Use `imageVariants` for display. Use `originalUrl` when you need the unmodified source file. ## Image transformations The `imageVariants` field applies server-side transformations before returning the image URL. You define transformations in the query, so no client-side resizing is needed. ### Fit modes The `fit` parameter controls how the image is constrained within the specified `width` and `height`. Both width and height must be specified for fit to take effect. | Mode | Behavior | |---|---| | `BOUNDS` | Scales the image to fit within the dimensions, maintaining aspect ratio. The resulting image may be smaller than the specified size. | | `COVER` | Scales the image to completely fill the dimensions, cropping if needed. | | `CROP` | Resizes and crops the image centrally to exactly match the dimensions. | ### Output formats Use the `format` parameter to control the output format: | Format | Description | |---|---| | `AUTO` | Automatically selects the best format based on browser support and image characteristics. Recommended for most use cases. | | `WEBP` | Good compression with transparency support. Widely supported. | | `AVIF` | Best compression but slower to encode. Growing browser support. | | `JPG` | Universal support. No transparency. | | `PNG` | Lossless with transparency. Larger file sizes. | Other formats available: `GIF`, `BJPG` (baseline JPEG), `PJPG` (progressive JPEG), `JXL` (JPEG XL), `PNG8` (palette PNG), `WEBPLL` (lossless WebP), `WEBPLY` (lossy WebP). ### Canvas and background color Use `canvas` and `bgColor` together to produce images with consistent dimensions. This is useful for product listing grids where all thumbnails should be the same size regardless of the original image proportions. ```graphql imageVariants( input: { transformations: [ { name: "grid" transformation: { width: 200 height: 200 fit: BOUNDS bgColor: "transparent" canvas: { width: 200, height: 200 } } } ] } ) { name url } ``` The image is first scaled to fit within 200×200 pixels (`fit: BOUNDS`), then placed on a 200×200 canvas. The remaining space is filled with the `bgColor`. This guarantees every image in the grid is exactly 200×200 pixels, with the product centered. ### Other transformation options The transformation API supports additional parameters for advanced use cases: | Parameter | Description | |---|---| | `quality` | Compression quality (1 to 100) | | `dpr` | Device pixel ratio (1 to 10), for retina displays | | `blur` | Gaussian blur (0.5 to 1000) | | `brightness` | Brightness adjustment (-100 to 100) | | `contrast` | Contrast adjustment (-100 to 100) | | `saturation` | Saturation adjustment (-100 to 100) | | `sharpen` | Sharpening settings | ## Requesting multiple sizes in one query Pass an array of transformations to request multiple sizes in a single query. This is efficient for responsive images where you need a thumbnail, a medium size and a large detail image. ```graphql imageVariants( input: { transformations: [ { name: "thumbnail" transformation: { width: 100 height: 100 fit: BOUNDS bgColor: "transparent" canvas: { width: 100, height: 100 } } } { name: "medium" transformation: { width: 400 height: 400 fit: BOUNDS format: WEBP } } { name: "large" transformation: { width: 800 height: 800 fit: BOUNDS format: WEBP } } ] } ) { name url } ``` Each variant in the response is identified by `name`, so you can pick the right URL for each context: `thumbnail` for product listing cards, `medium` for category pages and `large` for product detail galleries. ## Querying product videos Use `media.videos` to fetch product videos: ```graphql query GetProductVideos($productId: Int!) { product(productId: $productId) { ... on Product { media { videos(search: { sort: ASC, page: 1, offset: 12 }) { items { id alt { language value } description { language value } priority videos { language uri mimeType } } itemsFound } } } } } ``` **Expected response:** ```json { "data": { "product": { "media": { "videos": { "items": [ { "id": "67e62e23-1eb0-4ef8-b1a4-65bea27a7422", "alt": [ { "language": "NL", "value": "Product demonstration video" } ], "description": [ { "language": "NL", "value": "Product demonstration video" } ], "priority": 1, "videos": [ { "language": "NL", "uri": "https://www.youtube.com/embed/example-product-demo", "mimeType": "video/mp4" } ] } ], "itemsFound": 1 } } } } } ``` The `uri` field contains the video URL. This can be a YouTube embed URL, a Vimeo URL or any hosted video URL. Videos are localized, so different languages can have different video URIs. ## Querying product documents Use `media.documents` to fetch downloadable files like spec sheets, manuals and safety data sheets: ```graphql query GetProductDocuments($productId: Int!) { product(productId: $productId) { ... on Product { media { documents(search: { sort: ASC, page: 1, offset: 12 }) { items { id alt { language value } description { language value } priority documents { language originalUrl mimeType } } itemsFound } } } } } ``` **Expected response:** ```json { "data": { "product": { "media": { "documents": { "items": [ { "id": "27760524-bce0-46ef-947d-464472f734fe", "alt": [ { "language": "NL", "value": "Product specification sheet" } ], "description": [ { "language": "NL", "value": "Product specification sheet" } ], "priority": 1, "documents": [ { "language": "en", "originalUrl": "https://media.helice.cloud/example/documents/en/27760524-product-spec-sheet.pdf", "mimeType": "application/pdf" } ] } ], "itemsFound": 1 } } } } } ``` Use `mimeType` to determine how to handle the file. For example, render PDFs inline or show a download button for other file types. ## Querying media by cluster or category The `Cluster` and `Category` types do not have a `media` field directly. To fetch media for a cluster or category, use the root-level `media` query with a `clusterId` or `categoryId` filter: ```graphql query GetClusterImages($clusterId: Int) { media { images(search: { clusterId: $clusterId, sort: ASC, page: 1, offset: 12 }) { items { id alt { language value } priority images { originalUrl mimeType } imageVariants( input: { transformations: [ { name: "detail" transformation: { width: 600 height: 600 fit: BOUNDS format: WEBP } } ] } ) { name url } } itemsFound } } } ``` The root `media` query accepts `productId`, `clusterId` and `categoryId` as filters in its search input. The same pattern works for `videos` and `documents`. Alternatively, you can access cluster media through the cluster's products: ```graphql query { cluster(clusterId: 456) { defaultProduct { media { images(search: { sort: ASC }) { items { id imageVariants( input: { transformations: [ { name: "detail" transformation: { width: 600, height: 600, fit: BOUNDS } } ] } ) { name url } } } } } } } ``` ## Pagination and sorting All media queries support pagination and sorting through the search input: | Parameter | Default | Description | |---|---|---| | `page` | `1` | Current page number | | `offset` | `12` | Number of items per page | | `sort` | `ASC` | Sort direction: `ASC` or `DESC` | The response includes pagination metadata: | Field | Description | |---|---| | `itemsFound` | Total number of media items matching the query | | `page` | Current page number | | `pages` | Total number of pages | | `offset` | Items per page | Images are sorted by `priority`. A lower priority value means the image appears first when sorted `ASC`. Use priority to control which image is shown as the primary product photo. ## Next steps - [Understanding products and categories](/frontend/domain-guides/products-and-catalog/understanding-products-and-categories) for how products, clusters and categories relate - [Querying products](/frontend/domain-guides/products-and-catalog/querying-products) for filtering, pagination and search - [Get product images with responsive transformations](/frontend/recipes/get-product-images-with-transformations) for a ready-to-use recipe --- ## Product Detail Queries URL: https://docs.propeller-commerce.com/frontend/domain-guides/products-and-catalog/product-detail-queries # Product detail queries Fetch detailed data for a single product including attributes, cross-sell relations and bundles. For listing queries (pagination, filtering, sorting), see [Querying products](/frontend/domain-guides/products-and-catalog/querying-products). ## Fetching a single product Use the `product` query to retrieve a single product by `productId`, `slug` or `sku`: ```graphql query GetProduct($slug: String, $language: String) { product(slug: $slug) { productId sku names(language: $language) { value } descriptions(language: $language) { value } shortDescriptions(language: $language) { value } slugs(language: $language) { value } manufacturer manufacturerCode status price { gross net } inventory { totalQuantity } cluster { clusterId names(language: $language) { value } } categoryPath { categoryId name(language: $language) { value } } } } ``` **Variables:** ```json { "slug": "led-paneel-backlit-60x60-3000k", "language": "NL" } ``` **Response:** ```json { "data": { "product": { "productId": 2151, "sku": "LED-PNL-6060-3K", "names": [{ "value": "LED Paneel Backlit 60x60 3000K" }], "descriptions": [{ "value": "
Compact backlit LED paneel van 60x60 cm...
" }], "shortDescriptions": [{ "value": "Backlit LED paneel 60x60 cm, warm wit 3000K, voor systeemplafonds
" }], "slugs": [{ "value": "led-paneel-backlit-60x60-3000k" }], "manufacturer": "ProLight Solutions", "manufacturerCode": "PL-6060-BL-30", "status": "A", "price": { "gross": 39.95, "net": 48.34 }, "inventory": { "totalQuantity": 250 }, "cluster": { "clusterId": 214, "names": [{ "value": "LED Paneel Backlit" }] }, "categoryPath": [] } } } ``` You can also look up by `productId` or `sku`: ```graphql product(productId: 2151) { ... } product(sku: "LED-PNL-6060-3K") { ... } ``` If the product belongs to a cluster, the `cluster` field returns the parent cluster. Use this to link back to the cluster page or show variant selectors. The `categoryPath` field returns the full category hierarchy for building breadcrumbs. ## Product attributes Attributes are key-value pairs attached to products. Use the `attributes` field to fetch them for display as product specifications. ```graphql query GetProductAttributes($productId: Int!, $language: String) { product(productId: $productId) { productId names(language: $language) { value } attributes( input: { offset: 20 attributeDescription: { isPublic: true, isSearchable: true } } ) { items { attributeDescription { name descriptions { language value } type group } value { ... on AttributeColorValue { colorValue } ... on AttributeTextValue { textValues { language values } } ... on AttributeEnumValue { enumValues } ... on AttributeIntValue { intValue } ... on AttributeDecimalValue { decimalValue } ... on AttributeDateTimeValue { dateTimeValue } } } itemsFound } } } ``` **Variables:** ```json { "productId": 2151, "language": "NL" } ``` **Response:** ```json { "data": { "product": { "productId": 2151, "names": [{ "value": "LED Paneel Backlit 60x60 3000K" }], "attributes": { "items": [ { "attributeDescription": { "name": "AFMETING_MM", "descriptions": [{ "language": "NL", "value": "Afmeting mm" }], "type": "TEXT", "group": null }, "value": { "textValues": [{ "language": "NL", "values": ["60 x 60 cm"] }] } }, { "attributeDescription": { "name": "KLEURTEMPERATUUR", "descriptions": [{ "language": "NL", "value": "Kleurtemperatuur in Kelvin" }], "type": "TEXT", "group": "LED verlichting" }, "value": { "textValues": [{ "language": "NL", "values": ["3000K"] }] } } ], "itemsFound": 2 } } } } ``` Attribute values use different types depending on the attribute's `type`. Each type has its own fragment: | Attribute type | Fragment | Value field | |---|---|---| | `TEXT` | `AttributeTextValue` | `textValues` (localized array) | | `ENUM` | `AttributeEnumValue` | `enumValues` (string array) | | `COLOR` | `AttributeColorValue` | `colorValue` (hex string) | | `INT` | `AttributeIntValue` | `intValue` | | `DECIMAL` | `AttributeDecimalValue` | `decimalValue` | | `DATETIME` | `AttributeDateTimeValue` | `dateTimeValue` | Include all six fragments in your query to handle any attribute type. The `attributeDescription` provides the human-readable label via `descriptions` and an optional `group` for organizing attributes into sections. ### Filtering attributes The `attributeDescription` input controls which attributes are returned: | Filter | Description | |---|---| | `isPublic` | Attributes intended for end-user display | | `isSearchable` | Attributes used for catalog filtering | | `names` | Filter by specific attribute names | | `groups` | Filter by attribute group | | `types` | Filter by attribute type (`TEXT`, `ENUM`, etc.) | Set `includeDefaultValues` to `false` to only return attributes that have a value set on the product, excluding inherited defaults. ## Cross-sell and upsell Products and clusters can have cross-sell and upsell relations. Use `crossupsellsFrom` to fetch them: ```graphql query GetCrossSellProducts($productId: Int!, $language: String) { product(productId: $productId) { productId names(language: $language) { value } crossupsellsFrom(input: { offset: 12 }) { items { type subType productTo { ... on Product { productId names(language: $language) { value } price { gross net } } } clusterTo { ... on Cluster { clusterId names(language: $language) { value } } } } itemsFound } } } ``` **Variables:** ```json { "productId": 25, "language": "NL" } ``` **Response:** ```json { "data": { "product": { "productId": 25, "names": [{ "value": "Industriële Kachel HT-1001" }], "crossupsellsFrom": { "items": [ { "type": "RELATED", "subType": null, "productTo": { "productId": 26, "names": [{ "value": "Industriële Kachel HT-350" }], "price": { "gross": 2275.21, "net": 2753.0 } }, "clusterTo": null }, { "type": "PARTS", "subType": null, "productTo": { "productId": 28, "names": [{ "value": "Vuurvaste Bodemplaat HT-425" }], "price": { "gross": 172.44, "net": 208.65 } }, "clusterTo": null }, { "type": "ACCESSORIES", "subType": null, "productTo": { "productId": 31, "names": [{ "value": "Hitteschild Links HT-101" }], "price": { "gross": 70.0, "net": 84.7 } }, "clusterTo": null } ], "itemsFound": 22 } } } } ``` Each relation has a `type` that indicates the relationship. For the available types and their meaning, see [Understanding products and categories](/frontend/domain-guides/products-and-catalog/understanding-products-and-categories#cross-sell-and-upsell-relations). Each relation points to either a `productTo` (a standalone product) or a `clusterTo` (a cluster), never both. Both fields return the `IBaseProduct` interface, so use inline fragments (`... on Product` and `... on Cluster`) to access type-specific fields like `productId`, `price` or `clusterId`. ## Bundle details Products can be part of bundles. The `bundles` field returns all bundles where this product participates: ```graphql query GetProductBundleDetails($productId: Int!, $language: String) { product(productId: $productId) { productId names(language: $language) { value } bundles { id name discount condition price { gross net originalGross originalNet } items { productId isLeader product { names(language: $language) { value } sku price { gross net } } } } } } ``` **Variables:** ```json { "productId": 2017, "language": "NL" } ``` **Response:** ```json { "data": { "product": { "productId": 2017, "names": [{ "value": "Ergonomische Bureaustoel Pro 300 Mesh Zwart" }], "bundles": [ { "id": "019c9425-5eaf-72b5-9315-d7182932db45", "name": "Bureaustoel Pro + Vloermat", "discount": 10, "condition": "EP", "price": { "gross": 270.89, "net": 327.77, "originalGross": 272.65, "originalNet": 329.91 }, "items": [ { "productId": 2017, "isLeader": "Y", "product": { "names": [{ "value": "Ergonomische Bureaustoel Pro 300 Mesh Zwart" }], "sku": "BST-PRO-300", "price": { "gross": 255.0, "net": 308.55 } } }, { "productId": 2044, "isLeader": "N", "product": { "names": [{ "value": "Vloermat PVC Transparant 120 x 90 cm" }], "sku": "0123AA", "price": { "gross": 17.65, "net": 21.36 } } } ] } ] } } } ``` The `isLeader` field (`Y` or `N`) identifies the main product in the bundle. The leader is the product the bundle is displayed on. Compare `originalGross`/`originalNet` (sum of individual prices) with `gross`/`net` (discounted bundle price) to show the savings. To filter product listings to only products with bundle offers, use `hasBundle: Y` in `ProductSearchInput`. For adding bundles to a cart, see [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management). ## Next steps - [Understanding products and categories](/frontend/domain-guides/products-and-catalog/understanding-products-and-categories) — how clusters, products, attributes and bundles work - [Querying products](/frontend/domain-guides/products-and-catalog/querying-products) — listing queries with filtering, pagination and search - [Media and assets](/frontend/domain-guides/products-and-catalog/media-and-assets) — product images, videos and documents - [Understanding pricing layers](/frontend/domain-guides/pricing-and-discounts/understanding-pricing-layers) — price types, bulk prices and surcharges --- ## Querying Products URL: https://docs.propeller-commerce.com/frontend/domain-guides/products-and-catalog/querying-products # Querying products Fetch products from the Propeller catalog using GraphQL. This page covers how to retrieve product listings, handle pagination, work with filters and deal with clusters (product groups) in your query results. ## Fetching products The `products` query returns a paginated list of catalog items. Each item is either a **Product** (a single purchasable item) or a **Cluster** (a group of related products). Your query needs to handle both types. ```graphql query GetProducts($language: String) { products { items { ... on Product { productId sku names(language: $language) { value } price { gross net } slugs(language: $language) { value } media { images(search: { sort: ASC, offset: 1 }) { items { alt(language: $language) { value } imageVariants( input: { transformations: { name: "small" transformation: { width: 50 height: 50 fit: BOUNDS format: WEBP } } } ) { url } } } } } ... on Cluster { names(language: $language) { value } sku defaultProduct { productId price { gross net } slugs(language: $language) { value } media { images(search: { sort: ASC, offset: 1 }) { items { imageVariants( input: { transformations: { name: "small" transformation: { width: 50 height: 50 fit: BOUNDS format: WEBP } } } ) { url } } } } } } } itemsFound page pages } } ``` The response contains both products and clusters in the same `items` array. For clusters, use `defaultProduct` to get the price, images and slug to display on a listing page. The cluster itself holds shared data like the name. You can filter results to only products or only clusters using the `class` parameter in `ProductSearchInput` with values `PRODUCT` or `CLUSTER`. ## Pagination Control how many products appear per page using `offset` (items per page) and `page` (current page number). The response includes `itemsFound`, `pages`, `start` and `end` so you can build pagination controls. ```graphql query GetProducts($page: Int, $offset: Int) { products(input: { page: $page, offset: $offset }) { items { ... on Product { productId } } itemsFound start end page pages } } ``` **Variables:** ```json { "page": 2, "offset": 24 } ``` Categories and other list queries use the same pagination pattern. ## Filtering by attributes Products have attributes (like color, size or material) that you can use as filters. First retrieve the available filters for the current product set, then pass selected values back into the query. ### Get available filters ```graphql query CatalogQuery($input: ProductSearchInput) { products(input: $input) { filters(input: { isPublic: true, isSearchable: true }) { attributeDescription { name descriptions { language value } } textFilters { value count isSelected } decimalRangeFilter { min max } type } items { ... on Product { productId } } itemsFound } } ``` The `filters` field returns two types: `textFilters` for discrete values (colors, brands) and `decimalRangeFilter` for numeric ranges (price, weight). Each text filter includes a `count` of matching products so you can show facet counts in your UI. ### Apply selected filters Pass the user's selections back through `ProductSearchInput.textFilters` and `ProductSearchInput.rangeFilters`: ```graphql query CatalogQuery { products( input: { term: "valve" price: { from: 0, to: 500 } textFilters: [{ name: "COLOR", values: ["red", "blue"] }] } ) { items { ... on Product { productId } } itemsFound page pages } } ``` The `term` field performs a text search across product fields. Use `searchFields` to control which fields are included in the search. Filters combine with AND logic: the query above returns products matching "valve" that cost under 500 and are red or blue. ### ProductSearchInput reference The `ProductSearchInput` type supports the following fields: | Field | Description | |---|---| | `term` | Text search across product fields | | `searchFields` | Control which fields `term` searches | | `categoryId` | Filter by category | | `textFilters` | Filter by text/enum attribute values | | `rangeFilters` | Filter by numeric attribute ranges | | `price` | Filter by price range (requires both `from` and `to`) | | `sortInputs` | Sort by field (`NAME`, `PRICE`, `SKU`, `RELEVANCE`, etc.) | | `manufacturers` | Filter by manufacturer name | | `skus` | Filter by SKU codes | | `statuses` | Filter by product status (default: active) | | `hasBundle` | Filter to products with bundle offers (`Y` or `N`) | | `isBundleLeader` | Filter to bundle leader products (`Y` or `N`) | | `applyOrderlists` / `orderlistIds` | Restrict to orderlist assortment | | `class` | Filter by `PRODUCT` or `CLUSTER` | | `hidden` | Include or exclude hidden products | | `containerSlugs` / `containerPathSlugs` | Filter by path slugs | | `page` / `offset` | Pagination (default: page 1, 12 items per page) | For the full schema definition see the [ProductSearchInput reference](/reference/graphql/inputs/product-search-input). ## Filtering by orderlist In B2B commerce, different companies see different product assortments. Orderlists control which products are visible and orderable for a specific company. Pass orderlist IDs to restrict results to products the customer is allowed to see. ```graphql query GetOrderListProducts { products(input: { applyOrderlists: true, orderlistIds: [1, 3, 678] }) { items { ... on Product { productId } } itemsFound } } ``` When `applyOrderlists` is true, only products assigned to the specified orderlists are returned. ## Sorting results Use `sortInputs` in `ProductSearchInput` to order results. Available sort fields include `NAME`, `PRICE`, `SKU`, `RELEVANCE`, `CREATED_AT`, `LAST_MODIFIED_AT`, `PRIORITY` and `CATEGORY_ORDER`. ```graphql query GetProducts { products(input: { sortInputs: [{ field: NAME, order: ASC }] }) { items { ... on Product { productId } } } } ``` ## Including inventory in listings You can include stock data directly in your product listing query to show availability badges or "in stock" indicators without a separate request. ```graphql query GetProducts { products { items { ... on Product { productId inventory { totalQuantity } } } } } ``` `totalQuantity` is the combined stock across all warehouses and suppliers. For a detailed breakdown by warehouse, see the recipe [Fetch product stock across warehouses](/frontend/recipes/fetch-product-stock). --- ## Understanding Products and Categories URL: https://docs.propeller-commerce.com/frontend/domain-guides/products-and-catalog/understanding-products-and-categories # Understanding products and categories Propeller's catalog is built around two core types: **products** and **clusters**. A product is the standalone, purchasable unit. A cluster groups multiple products together as variants of the same item. Both live inside a category tree and can carry cross-sell and upsell relations, pricing, inventory, attributes and media. ## Products A product (`class: PRODUCT`) is the fundamental unit in Propeller. Every item that can be ordered, priced and stocked is a product. Products are identified by their `productId`. ### Core fields Each product carries identification and descriptive fields: | Field | Description | |---|---| | `sku` | Stock keeping unit code | | `eanCode` | European Article Number | | `barCode` | Barcode | | `names` | Product name, localized per language | | `shortNames` | Abbreviated name, used in compact displays like order lines | | `descriptions` | Full description (localized, supports HTML) | | `shortDescriptions` | Short description (localized, supports HTML) | | `manufacturer` | Manufacturer name | | `manufacturerCode` | Manufacturer's own product code | | `supplier` | Supplier name | | `supplierCode` | Supplier's own product code | ### Ordering and packaging Products define how they can be ordered and how they are packaged: | Field | Description | |---|---| | `unit` | The base unit in which the product is ordered | | `minimumQuantity` | Minimum order quantity | | `economicOrderQuantity` | The quantity that provides the best value | | `purchaseUnit` | Unit for purchase orders | | `purchaseMinimumQuantity` | Minimum quantity for purchase orders | | `package` | Package type (e.g. `BOX`) | | `packageUnit` | What one item in the package looks like (e.g. `PC`) | | `packageUnitQuantity` | Number of items per package | ### Status Every product has a `status` that controls its availability: | Status | Meaning | |---|---| | `A` | Available | | `N` | Not available | | `P` | Phase out | | `S` | Presale | | `R` | Restricted | | `T` | Out of stock | Additional flags control visibility and behavior: `hidden` (Y/N), `orderable` (Y/N) and `physical` (Y/N, indicating whether it is a physical product or something like a service or warranty). ### Standalone vs. cluster products A product can exist on its own (standalone) or as a variant inside a cluster. The `containerClass` field tells you which situation applies: - `CATEGORY` means the product lives standalone in a category. - `CLUSTER` means the product belongs to a cluster. You can navigate to its parent cluster via the `cluster` field. ### SEO metadata Products carry localized SEO fields: `metadataTitles`, `metadataDescriptions`, `metadataKeywords` and `metadataCanonicalUrls`. ## Clusters A cluster (`class: CLUSTER`) groups multiple products together as variants of the same item. Clusters are identified by their `clusterId`. Where a product is what gets ordered, a cluster is the presentation layer that ties variants together on a single product page. ### Cluster-level vs. product-level data Clusters have their own `names`, `descriptions` and `shortDescriptions`. These are shared presentation data that apply to all variants. Each product inside the cluster carries its own variant-specific data: its own `sku`, `names`, pricing, inventory and attribute values. ### Cluster configuration Every cluster has a `config` (ClusterConfig) that defines which attributes differentiate its variants. The config contains settings, each specifying: | Setting field | Description | |---|---| | `name` | The attribute name that varies across products (e.g. `AFMETING_MM`, `KLEURTEMPERATUUR`) | | `displayType` | How the selector should render: `DROPDOWN`, `RADIO`, `IMAGE` or `COLOR` | | `priority` | Display order (0 is first) | For example, an LED panel cluster uses config settings `AFMETING_MM` (priority 0) and `KLEURTEMPERATUUR` (priority 1). Each product in the cluster then has specific values for these attributes: ``` Cluster: "LED Paneel Backlit" ├── LED Paneel Backlit 60x60 3000K (AFMETING_MM: 60 x 60 cm, KLEURTEMPERATUUR: 3000K) ├── LED Paneel Backlit 60x60 4000K (AFMETING_MM: 60 x 60 cm, KLEURTEMPERATUUR: 4000K) ├── LED Paneel Backlit 60x60 6000K (AFMETING_MM: 60 x 60 cm, KLEURTEMPERATUUR: 6000K) ├── LED Paneel Backlit 30x120 3000K (AFMETING_MM: 30 x 120 cm, KLEURTEMPERATUUR: 3000K) ├── LED Paneel Backlit 30x120 4000K (AFMETING_MM: 30 x 120 cm, KLEURTEMPERATUUR: 4000K) └── ... ``` ### Default product Every cluster has a `defaultProduct`. This is the variant that should be shown when no specific selection has been made, for example on listing pages where you need a representative price and image. ### Cluster options Clusters can have **options**, which are add-on product groups. Options are not variants. They represent additional items a customer can select alongside the main product, such as accessories or service add-ons. Each option has its own `names`, `descriptions`, an `isRequired` flag (Y/N) and a list of `products` to choose from. An option can also have a `defaultProduct`. For example, the LED panel cluster has two options: - **Opbouwframe** (optional): mounting frames in different sizes, 3 products to choose from. - **Noodverlichting module** (optional): emergency lighting modules, 2 products to choose from. ## Categories Products and clusters are organized into categories. Categories form a tree structure where each category has a `parent` (except root categories) and can have child `categories`. Each product and cluster belongs to one primary category via `categoryId`. The `categoryPath` field returns the full hierarchy from root to the assigned category, which is useful for building breadcrumbs. Products can also be linked to additional categories, allowing them to appear in multiple places in the catalogue structure without duplication. Example from a real catalog: ``` PDM (root) ├── Bureaustoelen │ ├── EM bureaustoelen │ ├── Interstuhl bureaustoelen │ └── ... ├── Elektrotechnisch & Industrieel Installatiemateriaal │ ├── LED verlichting │ └── ... └── ... ``` For practical details on querying and navigating categories, see [Categories and navigation](/frontend/domain-guides/products-and-catalog/categories-and-navigation). ## Cross-sell and upsell relations Products and clusters can have cross-sell and upsell relations via `crossupsellsFrom`. Each relation has a `type` and an optional `subType`: | Type | Purpose | |---|---| | `ACCESSORIES` | Complementary items (e.g. a floor mat for a desk chair) | | `ALTERNATIVES` | Substitute products (e.g. a different brand of chair) | | `RELATED` | Related items from the same category or brand | | `PARTS` | Component parts of the product | | `OPTIONS` | Optional add-ons | Each relation points to either a `productTo` (a standalone product) or a `clusterTo` (a cluster), never both. This means cross-sell targets can be individual products or entire product groups with variants. For example, a desk chair product might have: - **ACCESSORIES**: four different floor mats (all pointing to standalone products). - **ALTERNATIVES**: chairs from other brands, including one relation pointing to a cluster with its own variants. - **RELATED**: other chairs from the same manufacturer. ## How this relates to pricing Pricing lives on products, not on clusters. Each product has a `priceData` field with the base price configuration: | Field | Description | |---|---| | `list` | The default sales price | | `store` | The in-store price (can differ from the list price) | | `suggested` | Manufacturer's recommended retail price | | `per` | The quantity the listed price applies to | | `defaultTaxCode` | Tax code: `H` (high), `L` (low) or `N` (none) | | `bulkPriceDiscountType` | How bulk discounts are applied: as a net price or as a percentage | On top of the base price, products can have **bulk prices** (tiered pricing based on `quantityFrom`) and **cost prices** (purchase prices, also with `quantityFrom`). When requesting prices for a specific customer context, use the `price` field which returns calculated prices including `net`, `gross` and any applicable discounts. For the full pricing model, see [Understanding pricing layers](/frontend/domain-guides/pricing-and-discounts/understanding-pricing-layers). ## How this relates to inventory Inventory lives on products. The `inventory` field provides: - `totalQuantity`: the aggregate stock across all warehouses. - `localQuantity`: stock in local (own) warehouses. - `supplierQuantity`: stock held by suppliers. - `nextDeliveryDate`: the earliest expected restocking date. - `balance[]`: a per-warehouse breakdown, where each entry includes `quantity`, `warehouseId`, `supplier` and `nextDeliveryDate`. A single product can have stock spread across multiple warehouses and multiple suppliers. ## How this relates to attributes Attributes are key-value pairs attached to products. Each attribute is defined by an `attributeDescription` that specifies the attribute's `name`, `type`, `group` and behavior flags (`isSearchable`, `isPublic`, `isSystem`, `isHidden`). Supported attribute types: `TEXT`, `ENUM`, `COLOR`, `DATETIME`, `INT` and `DECIMAL`. Attributes are organized into groups (e.g. "Kantoormeubilair", "LED verlichting") and can be localized with descriptions and units per language. In clusters, attributes play a special role: the cluster's config settings reference specific attributes (by name) as the properties that differentiate variants. The attribute values on each product within the cluster then determine which variant is which. ## How this relates to media Media is attached to products via the `media` field, which contains three paginated sub-fields: - `images`: product photos with `imageVariants` (named sizes like `small`, `medium`), `alt` text, `description` and `priority` for ordering. - `videos`: product videos. - `documents`: downloadable files like PDFs and technical sheets. Images are served with transformation parameters for resizing and cropping, so you can request the appropriate size for thumbnails, listing pages or detail views. For practical details, see [Media and assets](/frontend/domain-guides/products-and-catalog/media-and-assets). ## How this relates to bundles Products can be part of bundles. A bundle groups multiple products together and sells them at a combined price, typically with a discount compared to buying each item individually. Each bundle has: | Field | Description | |---|---| | `id` | Unique bundle identifier | | `name` | Bundle name | | `discount` | Discount applied to the bundle | | `condition` | Rules for when the bundle applies | | `price` | Combined bundle price (gross, net, original prices) | | `items` | The products included in the bundle | One product in a bundle is the **leader** (marked with `isLeader`). The leader is the main product that the bundle is displayed on. The other items are complementary products included in the bundle offer. Bundles are returned as a list on the product via the `bundles` field. For adding bundles to a cart, see [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management). --- ## Frontend Patterns URL: https://docs.propeller-commerce.com/frontend/frontend-patterns # Frontend Patterns Patterns that apply when building on Propeller's GraphQL API, regardless of your framework choice. - [Public vs authenticated data](/frontend/frontend-patterns/public-vs-authenticated-data) — which data changes based on authentication state and how that affects your architecture --- ## Public vs Authenticated Data URL: https://docs.propeller-commerce.com/frontend/frontend-patterns/public-vs-authenticated-data # Public vs authenticated data Propeller's GraphQL API returns different data depending on whether the request includes an authenticated session. Understanding which data is public and which is session-dependent helps you decide what to cache, pre-render or fetch per request. ## Public data Public data is the same for every visitor, whether anonymous or logged in. This includes: - **Product catalog**: names, descriptions, SKUs, attributes and media - **Categories**: the full category tree and navigation structure - **Default prices**: the list price (`priceData.list`) and suggested price (`priceData.suggested`) - **Product search and filtering**: search results when not restricted by orderlists This data is safe to cache, statically generate or serve from a CDN. It does not depend on who is viewing it. ## Session-dependent data Session-dependent data changes based on the authenticated user. This data must be fetched per request with the user's session context. ### Prices Authenticated users who belong to a company with a price sheet see different prices than anonymous users. The `price` field automatically reflects the applicable price sheet when queried with an authenticated session. The `price.type` field tells you where the price came from: - `DEFAULT` for anonymous users or users without a price sheet (the list price) - `PRICESHEET` for users with customer-specific pricing Anonymous users always see the list price. See [Customer-specific pricing](/frontend/domain-guides/pricing-and-discounts/customer-specific-pricing) for details. ### Product visibility In B2B, orderlists control which products are visible and orderable for a specific company. When you query products with `applyOrderlists: true`, only products assigned to the company's orderlists are returned. Anonymous users without orderlist filtering see the full catalog. See [Querying products](/frontend/domain-guides/products-and-catalog/querying-products#filtering-by-orderlist) for how to apply orderlist filtering. ### Cart Carts belong to the current session. An anonymous session has its own cart. When a user logs in, the cart is associated with their account. Cart data is always session-scoped and cannot be cached across users. See [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) for details. ### Favorite lists Favorite lists belong to a contact, customer or company. They are only accessible when authenticated. See [Favorite lists](/frontend/domain-guides/accounts-and-authentication/favorite-lists) for how favorite list ownership works. ### Order history Orders are only visible to authenticated users. In B2B, orders can be filtered by company so that a procurement manager sees all orders placed by any contact in the company. See [Order history](/frontend/domain-guides/orders-and-shipments/order-history) for listing and filtering orders. ### Account data The `viewer` query returns the current user's identity. For authenticated users, it returns a `Contact` (B2B) or `Customer` (B2C) with their profile data. For anonymous users, the response indicates that no user is logged in. ## Detecting authentication state Use the `viewer` query to determine whether the current session is authenticated: ```graphql query { viewer { __typename isLoggedIn } } ``` When `isLoggedIn` is `true`, the user has an active session and you can fetch session-dependent data. The `__typename` field returns `Contact` for B2B users or `Customer` for B2C users, which determines what account features to show. See [Authentication and authorization](/frontend/domain-guides/accounts-and-authentication/authentication-and-authorization#the-viewer-query) for the full viewer query and response examples. ## Practical implications **Catalog pages** can be built with public data for fast initial loads. Product names, descriptions, images, categories and default prices are all public. Cache or pre-render these freely. **Personalized data** (customer-specific prices, orderlist-filtered products) should be loaded once the user's session is known. A common approach is to render the catalog page with list prices first, then replace them with the customer's prices after authentication is confirmed. **"Log in for your price"** is a pattern used in B2B storefronts where prices vary significantly between customers. Check `price.type`: when it returns `DEFAULT`, the visitor is seeing the list price. When it returns `PRICESHEET`, they are seeing their negotiated price. Some B2B storefronts hide prices entirely for anonymous visitors and show a login prompt instead. **Cart and account pages** always require session context. There is no public fallback for these. ## Next steps - [Authentication and authorization](/frontend/domain-guides/accounts-and-authentication/authentication-and-authorization) for login, tokens and the viewer query - [Customer-specific pricing](/frontend/domain-guides/pricing-and-discounts/customer-specific-pricing) for how price sheets work - [Querying products](/frontend/domain-guides/products-and-catalog/querying-products#filtering-by-orderlist) for orderlist filtering - [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) for cart session behavior --- ## Frontend Guides URL: https://docs.propeller-commerce.com/frontend # Frontend Development Guides Guides for partners building customer-facing experiences on Propeller. All content in this section uses the **GraphQL API**. Each topic starts with a concept explanation so you understand how Propeller handles the domain, followed by practical how-to guides with code examples. ## Frontend patterns Patterns that apply when building on Propeller's GraphQL API, regardless of your framework choice. - [Public vs authenticated data](/frontend/frontend-patterns/public-vs-authenticated-data) — which data changes based on authentication state and how that affects your architecture ## Products and catalog Understand Propeller's product model (clusters, products, variants and attributes) and learn how to query, filter and display product data. - [Understanding products and categories](/frontend/domain-guides/products-and-catalog/understanding-products-and-categories) — products and variants (clusters) - [Querying products](/frontend/domain-guides/products-and-catalog/querying-products) — filtering, pagination and search - [Product detail queries](/frontend/domain-guides/products-and-catalog/product-detail-queries) — attributes, cross-sell relations and bundles - [Categories and navigation](/frontend/domain-guides/products-and-catalog/categories-and-navigation) — category tree, breadcrumbs and navigation menus - [Media and assets](/frontend/domain-guides/products-and-catalog/media-and-assets) — images with transformations, videos and documents ## Accounts and authentication Account management for B2B and B2C: customers, contacts, companies, authentication and address management. - [Understanding companies, contacts and customers](/frontend/domain-guides/accounts-and-authentication/understanding-companies-contacts-and-customers) — B2B and B2C account entities - [Authentication and authorization](/frontend/domain-guides/accounts-and-authentication/authentication-and-authorization) — registration, login, tokens and session management - [Managing addresses](/frontend/domain-guides/accounts-and-authentication/managing-addresses) — address CRUD for customers and companies - [Favorite lists](/frontend/domain-guides/accounts-and-authentication/favorite-lists) — save products for quick access and reordering ## Pricing and discounts How Propeller's pricing layers work, including customer-specific pricing, tiered pricing and promotions. - [Understanding pricing layers](/frontend/domain-guides/pricing-and-discounts/understanding-pricing-layers) — price types, tax zones and surcharges - [Customer-specific pricing](/frontend/domain-guides/pricing-and-discounts/customer-specific-pricing) — price sheets, action codes and custom overrides - [Tiered and volume pricing](/frontend/domain-guides/pricing-and-discounts/tiered-and-volume-pricing) — bulk discounts and quantity breaks ## Cart and checkout The full order lifecycle from cart management through checkout and payment integration. - [Understanding the order lifecycle](/frontend/domain-guides/cart-and-checkout/understanding-the-order-lifecycle) — how cart, order and fulfillment relate - [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) — create carts, add items, manage state and persistence - [Checkout flow](/frontend/domain-guides/cart-and-checkout/checkout-flow) — addresses, shipping, payment and order creation - [Payment integration](/frontend/domain-guides/cart-and-checkout/payment-integration) — PSP integration, payment tracking and order confirmation ## Orders and shipments Order history, shipment tracking and reordering for the customer portal. - [Order history](/frontend/domain-guides/orders-and-shipments/order-history) — list, filter and view order details - [Shipment tracking](/frontend/domain-guides/orders-and-shipments/shipment-tracking) — display shipping information and tracking codes - [Reordering](/frontend/domain-guides/orders-and-shipments/reordering) — let customers reorder from previous orders ## Storefront SDK The Storefront SDK provides typed services, pre-built UI components and an optional Accelerator app for building customer-facing experiences. These guides cover what is specific to the SDK and build on top of the domain guides above. - [Understanding the Storefront SDK](/frontend/storefront-sdk/understanding-the-storefront-sdk) — three-layer architecture and how the pieces fit together - [Choosing Your Approach](/frontend/storefront-sdk/choosing-your-approach) — common partner architectures and a decision table - [SDK Services](/frontend/storefront-sdk/sdk-services) — typed TypeScript services for products, categories, carts, users and orders - [UI Components](/frontend/storefront-sdk/ui-components) — pre-built components, callbacks and stack-agnostic documentation - [Accelerator](/frontend/storefront-sdk/accelerator) — complete working storefront app with routing, CMS integration and state management - [CMS Integration](/frontend/storefront-sdk/cms-integration) — adapter pattern, bridge blocks and dynamic block rendering - [Routing](/frontend/storefront-sdk/routing) — commerce routes, CMS routes and URL configuration - [Customization](/frontend/storefront-sdk/customization) — callbacks, pass-through properties and override patterns - [B2B Capabilities](/frontend/storefront-sdk/b2b-capabilities) — portal modes, contact-company model and clusters ## WordPress Plugin In-depth guides for configuring and customizing the Propeller WordPress Plugin. Covers every admin tab, portal mode, multi-site and developer customization. - [Understanding the plugin](/frontend/wordpress-plugin/understanding-the-plugin) — how the plugin works under the hood - [General settings](/frontend/wordpress-plugin/general-settings) — API connection, channel, language, currency and portal mode - [Pages and shortcodes](/frontend/wordpress-plugin/pages-and-shortcodes) — page slugs, shortcodes and custom pages - [Behavior and display](/frontend/wordpress-plugin/behavior-and-display) — feature toggles and display options - [Translations and valuesets](/frontend/wordpress-plugin/translations-and-valuesets) — UI labels and predefined value lists - [Sitemaps](/frontend/wordpress-plugin/sitemaps) — XML sitemap generation from Propeller data - [Multi-site setup](/frontend/wordpress-plugin/multi-site-setup) — running the plugin across a WordPress Multisite network --- ## Add a product to cart URL: https://docs.propeller-commerce.com/frontend/recipes/add-product-to-cart # Add a product to cart Add an item to a cart and get the updated totals. ## Mutation ```graphql mutation AddToCart($cartId: String!, $productId: Int!, $quantity: Int!) { cartAddItem( id: $cartId input: { productId: $productId, quantity: $quantity } ) { items { itemId productId quantity price priceNet totalPrice totalPriceNet } total { totalGross totalNet } } } ``` ## Variables ```json { "cartId": "018dcc9a-f965-7434-8fad-369aa9a8c276", "productId": 67890, "quantity": 2 } ``` ## Response ```json { "data": { "cartAddItem": { "items": [ { "itemId": "a1b2c3", "productId": 67890, "quantity": 2, "price": 15.00, "priceNet": 18.15, "totalPrice": 30.00, "totalPriceNet": 36.30 } ], "total": { "totalGross": 30.00, "totalNet": 36.30 } } } } ``` ## How it works `cartAddItem` adds the product or increases its quantity if it already exists in the cart. The response returns all cart items with updated totals, so you can refresh the UI in one round trip. Note the price field naming: `price` and `totalPrice` are gross (excluding tax), while `priceNet` and `totalPriceNet` are net (including tax) — the opposite of what you might expect. Products may have a `minimumQuantity` or `purchaseUnit` constraint; the API rejects quantities that violate these. ## See also - [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) — full cart lifecycle guide --- ## Add product video URL: https://docs.propeller-commerce.com/frontend/recipes/add-product-video # Add product video Attach a video to a product, cluster or category using the GraphQL API. ## Mutation ```graphql mutation CreateMediaVideo($input: MediaVideoInput!) { mediaVideoCreate(input: $input) { id productId alt { language value } description { language value } priority createdAt } } ``` ## Variables ```json { "input": { "productId": "26", "alt": [ { "language": "NL", "value": "Industriële Kachel HT-350 installatiehandleiding" } ], "description": [ { "language": "NL", "value": "Stapsgewijze installatievideo" } ], "tags": [ { "language": "NL", "values": ["installatie", "handleiding"] } ], "priority": 1, "videos": [ { "language": "NL", "uri": "https://www.youtube.com/watch?v=abc123xyz" } ] } } ``` ## Response ```json { "data": { "mediaVideoCreate": { "id": "66d7bc7f690599fecc3fa6b3", "productId": "26", "alt": [{ "language": "NL", "value": "Industriële Kachel HT-350 installatiehandleiding" }], "description": [{ "language": "NL", "value": "Stapsgewijze installatievideo" }], "priority": 1, "createdAt": "2026-03-05T10:30:00.000Z" } } } ``` ## Attaching to other entities Replace `productId` with the appropriate field to attach media to other entities: | Field | Entity | |-------|--------| | `productId` | Product | | `clusterId` | Cluster | | `categoryId` | Category | ## How it works The `videos` array accepts one or more video entries, each with a `language` and a `uri`. The URI can be a YouTube or Vimeo link. The `priority` field controls display order (lower numbers appear first). Use `alt` for accessibility text and `description` for longer captions. ## See also - [Upload product images](/frontend/recipes/upload-product-images) — attach images to a product - [Fetch product videos](/frontend/recipes/fetch-product-videos) — retrieve videos for product detail pages --- ## Add products to order list URL: https://docs.propeller-commerce.com/frontend/recipes/add-products-to-order-list # Add products to order list Add products to an existing order list. The mutation returns the updated list with all products. ## Mutation ```graphql mutation AddItemsToOrderlist($id: Int!, $input: OrderlistItemsInput!) { orderlistAddItems(id: $id, input: $input) { id products(input: { offset: 100, page: 1 }) { items { ... on Product { productId names(language: "NL") { value } } } itemsFound } } } ``` ## Variables ```json { "id": 230, "input": { "productIds": [31, 2151] } } ``` ## Response ```json { "data": { "orderlistAddItems": { "id": 230, "products": { "items": [ { "productId": 25, "names": [{ "value": "Industriële Kachel HT-1001" }] }, { "productId": 26, "names": [{ "value": "Industriële Kachel HT-350" }] }, { "productId": 28, "names": [{ "value": "Vuurvaste Bodemplaat HT-425" }] }, { "productId": 31, "names": [{ "value": "Hitteschild Links HT-101" }] }, { "productId": 2151, "names": [{ "value": "LED Paneel Backlit 60x60 3000K" }] } ], "itemsFound": 5 } } } } ``` ## How it works Pass the order list `id` and a list of `productIds` to add. The response includes the full product list so you can verify the update. Products can also be referenced by external source using `productSources` instead of `productIds`. ## See also - [Create an order list](/frontend/recipes/create-an-order-list) — create a new order list with initial products - [Assign users to order list](/frontend/recipes/assign-users-to-order-list) — assign contacts to a list --- ## Apply a discount code to the cart URL: https://docs.propeller-commerce.com/frontend/recipes/apply-discount-code # Apply a discount code to the cart Add an action code (discount code) to the cart and get the updated totals with the discount applied. ## Mutation ```graphql mutation ApplyActionCode($cartId: String!, $actionCode: String!) { cartAddActionCode( id: $cartId input: { actionCode: $actionCode } ) { actionCode total { subTotal totalGross totalNet discount discountNet } } } ``` ## Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728", "actionCode": "SUMMER20" } ``` ## Response ```json { "data": { "cartAddActionCode": { "actionCode": "SUMMER20", "total": { "subTotal": 4785.12, "totalGross": 5120.12, "totalNet": 6195.34, "discount": 957.02, "discountNet": 1157.99 } } } } ``` ## How it works The `actionCode` goes inside the `input` object. If the code is invalid or cannot be applied, the mutation returns an error — check for `CART_ACTION_CODE_ADD_ERROR` in the response extensions. The `actionCode` field on the cart is only populated when a valid code is active. The `discount` and `discountNet` fields on the total reflect the reduction. To remove an applied code, use the `cartDeleteActionCode` mutation. ## See also - [Fetch cart with totals and tax breakdown](/frontend/recipes/fetch-cart-with-totals) --- ## Assign users to order list URL: https://docs.propeller-commerce.com/frontend/recipes/assign-users-to-order-list # Assign users to order list Assign contacts to an order list so the list's product restrictions apply to them. ## Mutation ```graphql mutation AssignUsersToOrderlist($id: Int!, $input: OrderlistUsersInput!) { orderlistAssignUsers(id: $id, input: $input) { id usersPaginated(input: { offset: 50, page: 1 }) { items { ... on Contact { contactId firstName lastName } } itemsFound } } } ``` ## Variables ```json { "id": 230, "input": { "userIds": [312, 42] } } ``` ## Response ```json { "data": { "orderlistAssignUsers": { "id": 230, "usersPaginated": { "items": [ { "contactId": 312, "firstName": "Lisa", "lastName": "de Vries" }, { "contactId": 42, "firstName": "Tom", "lastName": "Hendriks" } ], "itemsFound": 2 } } } } ``` ## How it works Pass the order list `id` and a list of `userIds` (contact IDs) to assign. The response includes the full user list so you can verify the assignment. To assign at the company level instead of individual contacts, use `orderlistAssignCompanies` with `companyIds`. ## See also - [Create an order list](/frontend/recipes/create-an-order-list) — create a new order list - [Add products to order list](/frontend/recipes/add-products-to-order-list) — add products to a list - [Understanding companies, contacts and customers](/frontend/domain-guides/accounts-and-authentication/understanding-companies-contacts-and-customers) — user types that can be assigned to lists --- ## Build a category navigation tree URL: https://docs.propeller-commerce.com/frontend/recipes/build-category-navigation-tree # Build a category navigation tree Query the full category hierarchy and render it as a nested menu with slugs for routing. ## Query ```graphql query GetCategories($language: String) { categories { items { ... on Category { categoryId name(language: $language) { value } slug(language: $language) { value } parent { categoryId } categoryPath { categoryId name(language: $language) { value } slug(language: $language) { value } } } } itemsFound } } ``` ## Variables ```json { "language": "EN" } ``` ## Response ```json { "data": { "categories": { "items": [ { "categoryId": 1794, "name": [{ "value": "ICT and hardware" }], "slug": [{ "value": "ict-and-hardware" }], "parent": null, "categoryPath": [] }, { "categoryId": 1795, "name": [{ "value": "Computers / laptops" }], "slug": [{ "value": "computers-laptops" }], "parent": { "categoryId": 1794 }, "categoryPath": [ { "categoryId": 1794, "name": [{ "value": "ICT and hardware" }], "slug": [{ "value": "ict-and-hardware" }] } ] }, { "categoryId": 1796, "name": [{ "value": "Business laptops" }], "slug": [{ "value": "business-laptops" }], "parent": { "categoryId": 1795 }, "categoryPath": [ { "categoryId": 1794, "name": [{ "value": "ICT and hardware" }], "slug": [{ "value": "ict-and-hardware" }] }, { "categoryId": 1795, "name": [{ "value": "Computers / laptops" }], "slug": [{ "value": "computers-laptops" }] } ] } ], "itemsFound": 3 } } } ``` ## How it works The response is a flat list of all categories. Each item has a `parent.categoryId` that tells you where it sits in the hierarchy — root categories have `parent: null`. Build a tree on the client by grouping items by their parent. Use `slug` values for URL routing and `categoryPath` for rendering breadcrumbs on category pages. ## See also - [Categories and navigation](/frontend/domain-guides/products-and-catalog/categories-and-navigation) — full guide on category queries and breadcrumbs --- ## Convert a quote into an order URL: https://docs.propeller-commerce.com/frontend/recipes/convert-quote-to-order # Convert a quote into an order Accept a quote and turn it into a confirmed order that can be fulfilled. ## Mutation ```graphql mutation ConvertQuoteToOrder($orderId: Int!, $status: String!) { orderSetStatus( input: { orderId: $orderId status: $status } ) { id status type createdAt total { gross net } } } ``` ## Variables ```json { "orderId": 2792, "status": "CONFIRMED" } ``` ## Response ```json { "data": { "orderSetStatus": { "id": 2792, "status": "CONFIRMED", "type": "quotation", "createdAt": "2026-02-12T14:22:08.000Z", "total": { "gross": 917.23, "net": 917.23 } } } } ``` ## How it works Use `orderSetStatus` to transition a quote (`type: quotation`, `status: QUOTATION`) to a confirmed order (`status: CONFIRMED`). Quotes can originate from a customer's quote request or be created directly by a sales rep — either way, the customer confirms from the portal using this mutation. This is typically triggered from a "Convert to order" button on the quote detail page. The order ID stays the same after the transition. The available status transitions depend on the order status set configured in your Propeller environment. ## See also - [Fetch quote or order detail](/frontend/recipes/fetch-order-detail) - [Fetch quote list](/frontend/recipes/fetch-quote-list) - [Submit a quote request from the cart](/frontend/recipes/submit-quote-request-from-cart) --- ## Create a channel URL: https://docs.propeller-commerce.com/frontend/recipes/create-a-channel # Create a channel Add a new channel to the platform. A channel represents a separate storefront or sales context (e.g. a B2B portal, a regional shop). ## Mutation ```graphql mutation CreateChannel($input: ChannelCreateInput!) { channelCreate(input: $input) { channelId name catalogRootId anonymousUserId } } ``` ## Variables ```json { "input": { "name": "Dealer Portal DE", "descriptions": [ { "language": "EN", "value": "German dealer portal" }, { "language": "NL", "value": "Duits dealerportaal" } ], "catalogRootId": 2050, "anonymousUserId": 14551 } } ``` ## Response ```json { "data": { "channelCreate": { "channelId": 72, "name": "Dealer Portal DE", "catalogRootId": 2050, "anonymousUserId": 14551 } } } ``` ## How it works Only the `name` field is required. Use `catalogRootId` to control which part of the category tree is visible on the channel. The `anonymousUserId` sets the default user context for unauthenticated visitors, controlling which prices and product visibility rules apply. Use `descriptions` to provide localized display names. ## See also - [List channels](/frontend/recipes/list-channels) — retrieve all configured channels - [Update a channel](/frontend/recipes/update-a-channel) — modify an existing channel - [Delete a channel](/frontend/recipes/delete-a-channel) — remove a channel --- ## Create an order list URL: https://docs.propeller-commerce.com/frontend/recipes/create-an-order-list # Create an order list Create an order list to control which products specific users or companies can (or cannot) order. ## Mutation ```graphql mutation CreateOrderlist($input: OrderlistCreateInput!) { orderlistCreate(input: $input) { id type active code descriptions { language value } validFrom validTo } } ``` ## Variables ```json { "input": { "type": "POSITIVE", "descriptions": [ { "language": "EN", "value": "Approved industrial heating products" }, { "language": "NL", "value": "Goedgekeurde industriële verwarmingsproducten" } ], "code": "HEAT-APPROVED-2026", "productIds": [25, 26, 28], "active": "Y" } } ``` ## Response ```json { "data": { "orderlistCreate": { "id": 230, "type": "POSITIVE", "active": "Y", "code": "HEAT-APPROVED-2026", "descriptions": [ { "language": "EN", "value": "Approved industrial heating products" }, { "language": "NL", "value": "Goedgekeurde industriële verwarmingsproducten" } ], "validFrom": null, "validTo": null } } } ``` ## List types | Type | Behavior | |------|----------| | `POSITIVE` | Assigned users/companies can **only** see and order products on this list. All other products are hidden. | | `NEGATIVE` | Assigned users/companies **cannot** see or order products on this list. All other products remain available. | ## How it works Order lists are a B2B access control mechanism. A `POSITIVE` list restricts a user's catalog to a curated set of approved products, while a `NEGATIVE` list hides specific products (e.g. hazardous materials, competitor brands). Products can be referenced by ID (`productIds`) or by external source (`productSources`). Use `validFrom` and `validTo` to make lists time-bound. Use `companyIds` or `userIds` on creation to assign the list immediately, or use the separate assign mutations afterward. ## See also - [Add products to order list](/frontend/recipes/add-products-to-order-list) — add more products to an existing list - [Assign users to order list](/frontend/recipes/assign-users-to-order-list) — assign contacts to a list - [Query products with customer-specific pricing](/frontend/recipes/query-products-with-customer-pricing) — order lists can also carry customer-specific pricing --- ## Create cross-sell and upsell relations URL: https://docs.propeller-commerce.com/frontend/recipes/create-cross-sell-and-upsell-relations # Create cross-sell and upsell relations Link two products or clusters as related, accessories, parts, alternatives or options. ## Mutation ```graphql mutation CreateCrossupsell($input: CrossupsellCreateInput!) { crossupsellCreate(input: $input) { id type subType productFrom { ... on Product { productId names(language: "NL") { value } } } productTo { ... on Product { productId names(language: "NL") { value } } } createdAt } } ``` ## Variables ```json { "input": { "type": "ACCESSORIES", "productIdFrom": 25, "productIdTo": 31 } } ``` ## Response ```json { "data": { "crossupsellCreate": { "id": "a1b2c3d4e5f6", "type": "ACCESSORIES", "subType": null, "productFrom": { "productId": 25, "names": [{ "value": "Industriële Kachel HT-1001" }] }, "productTo": { "productId": 31, "names": [{ "value": "Hitteschild Links HT-101" }] }, "createdAt": "2026-03-05T10:00:00.000Z" } } } ``` ## Linking clusters To link clusters instead of individual products, use `clusterIdFrom` and `clusterIdTo`: ```json { "input": { "type": "RELATED", "clusterIdFrom": 100, "clusterIdTo": 200 } } ``` You can also mix products and clusters (e.g. `productIdFrom` with `clusterIdTo`). ## Available relation types | Type | Use case | |------|----------| | `RELATED` | General "you might also like" recommendations | | `ACCESSORIES` | Add-on items that complement the main product | | `PARTS` | Replacement or spare parts for the main product | | `ALTERNATIVES` | Similar products the buyer could choose instead | | `OPTIONS` | Configurable options or upgrades | Use the optional `subType` string field to further classify relations within a type (e.g. `"subType": "mounting-kit"`). ## How it works The relation is directional: `productIdFrom` is the source product and `productIdTo` is the target. To create a bidirectional relation, create two crossupsell records with the IDs swapped. On the storefront side, use `crossupsellsFrom` on the product query to fetch outgoing relations. ## See also - [Display cross-sell and upsell products](/frontend/recipes/display-cross-sell-and-upsell-products) — query and render related products on a product detail page --- ## Create inventory URL: https://docs.propeller-commerce.com/frontend/recipes/create-inventory # Create inventory Add a stock record for a product in a warehouse or at a supplier. ## Mutation ```graphql mutation CreateInventory($input: CreateInventoryInput!) { inventoryCreate(input: $input) { id productId quantity warehouseId location supplier costPrice nextDeliveryDate notes total warehouse { id name } } } ``` ## Variables ```json { "input": { "type": "local", "productId": 26, "quantity": 500, "warehouseId": 1, "location": "AISLE-4-SHELF-B", "notes": "Initial stock import from ERP", "costPrice": 12.50, "nextDeliveryDate": "2026-04-01T00:00:00.000Z" } } ``` ## Response ```json { "data": { "inventoryCreate": { "id": "98422", "productId": 26, "quantity": 500, "warehouseId": 1, "location": "AISLE-4-SHELF-B", "supplier": "INTERN", "costPrice": 12.50, "nextDeliveryDate": "2026-04-01T00:00:00.000Z", "notes": "Initial stock import from ERP", "total": 500, "warehouse": { "id": 1, "name": "Main Warehouse" } } } } ``` ### Supplier inventory Use `type: "supplier"` to record stock held by a supplier rather than in a warehouse. The `supplier` field defaults to the product's supplier if not provided. ```json { "input": { "type": "supplier", "productId": 26, "quantity": 2000, "supplier": "Ingram Micro", "nextDeliveryDate": "2026-03-15T00:00:00.000Z", "notes": "Supplier confirmed availability" } } ``` ## Inventory types | Type | Behavior | |------|----------| | `local` | Stock in a warehouse. Defaults supplier to `INTERN`. | | `supplier` | Stock held by an external supplier. Defaults supplier to the product's supplier field. | ## How it works Each inventory record represents a stock quantity for one product in one warehouse (or at one supplier). A product can have multiple inventory records across different warehouses. The `total` field in the response sums up all inventory for that product. Use `location` to specify a bin or shelf position within a warehouse. The `nextDeliveryDate` field is informational and shown to buyers when a product is out of stock. ## See also - [Delete inventory](/frontend/recipes/delete-inventory) — remove an inventory record - [Fetch product stock across warehouses](/frontend/recipes/fetch-product-stock) — query inventory from the storefront --- ## Create sales tickets URL: https://docs.propeller-commerce.com/frontend/recipes/create-sales-tickets # Create sales tickets Create actionable tickets for sales reps in the Sales Hub. Tickets flag situations that need attention, such as inactive accounts, product recommendations or new registrations. ## Mutation ```graphql mutation CreateTicket($input: TicketCreateInput!) { ticketCreate(input: $input) { id titles { language value } descriptions { language value } type status companyId contactId productId orderId assignedToAdminUserId createdAt } } ``` ### Variables ```json { "input": { "titles": [ { "language": "NL", "value": "Inactief account: Brouwer Industrie" } ], "descriptions": [ { "language": "NL", "value": "Geen bestellingen in de afgelopen 90 dagen. Gemiddelde was 2 bestellingen per maand." } ], "type": "CHURN_RISK", "status": "OPEN", "companyId": 456, "assignedToAdminUserId": 10 } } ``` ### Response ```json { "data": { "ticketCreate": { "id": "01JNQXYZ1234567890AB", "titles": [{ "language": "NL", "value": "Inactief account: Brouwer Industrie" }], "descriptions": [{ "language": "NL", "value": "Geen bestellingen in de afgelopen 90 dagen. Gemiddelde was 2 bestellingen per maand." }], "type": "CHURN_RISK", "status": "OPEN", "companyId": 456, "contactId": null, "productId": null, "orderId": null, "assignedToAdminUserId": 10, "createdAt": "2026-03-05T10:00:00.000Z" } } } ``` ### More input examples Link a ticket to a specific product and contact: ```json { "input": { "titles": [ { "language": "NL", "value": "Productaanbeveling voor Brouwer Industrie" } ], "descriptions": [ { "language": "NL", "value": "Klant heeft product #25 drie keer besteld maar nog niet het aanvullende item #31." } ], "type": "CROSS_SELL", "status": "OPEN", "companyId": 456, "contactId": 312, "productId": 31 } } ``` Link a ticket to an order: ```json { "input": { "titles": [ { "language": "NL", "value": "Opvolging recente grote bestelling" } ], "descriptions": [ { "language": "NL", "value": "Bestelling #2791 was aanzienlijk groter dan gemiddeld. Controleer of de klant extra ondersteuning nodig heeft." } ], "type": "FOLLOW_UP", "status": "OPEN", "companyId": 456, "orderId": 2791 } } ``` ## Ticket statuses | Status | Meaning | |--------|---------| | `OPEN` | New ticket, not yet picked up | | `IN_PROGRESS` | Sales rep is working on it | | `COMPLETED` | Action taken, ticket resolved | | `ARCHIVED` | No longer relevant | ## How it works Tickets link a sales action to the relevant entities: a company, contact, product, order or cluster. The `assignedToAdminUserId` routes the ticket to a specific sales rep. The `type` field is a free-form string, so you can use any value that fits your workflow. The `status` field uses the `TicketStatus` enum (`OPEN`, `IN_PROGRESS`, `COMPLETED`, `ARCHIVED`). Use `externalUrl` to link to an external CRM or dashboard for additional context. Tickets can be created manually by a sales rep or automatically by an integration that analyzes order patterns. ## See also - [Understanding companies, contacts and customers](/frontend/domain-guides/accounts-and-authentication/understanding-companies-contacts-and-customers) — the entities tickets can reference --- ## Delete a channel URL: https://docs.propeller-commerce.com/frontend/recipes/delete-a-channel # Delete a channel Remove a channel from the platform. ## Mutation ```graphql mutation DeleteChannel($id: Int!) { channelDelete(id: $id) } ``` ## Variables ```json { "id": 72 } ``` ## Response ```json { "data": { "channelDelete": true } } ``` ## How it works The mutation returns `true` when the channel is successfully deleted. Deleting a channel does not remove the products or categories associated with it. The catalog root and its contents remain available for other channels. ## See also - [List channels](/frontend/recipes/list-channels) — retrieve all configured channels - [Create a channel](/frontend/recipes/create-a-channel) — add a new channel --- ## Delete inventory URL: https://docs.propeller-commerce.com/frontend/recipes/delete-inventory # Delete inventory Remove an inventory record for a product. ## Mutation ```graphql mutation DeleteInventory($id: Int!) { inventoryDelete(id: $id) { messages } } ``` ## Variables ```json { "id": 98422 } ``` ## Response ```json { "data": { "inventoryDelete": { "messages": [] } } } ``` ## How it works Pass the inventory record `id` (not the product ID) to delete a specific stock entry. An empty `messages` array indicates success. Deleting an inventory record does not affect other inventory records for the same product in different warehouses. ## See also - [Create inventory](/frontend/recipes/create-inventory) — add a stock record - [Fetch product stock across warehouses](/frontend/recipes/fetch-product-stock) — query inventory from the storefront --- ## Display cross-sell and upsell products URL: https://docs.propeller-commerce.com/frontend/recipes/display-cross-sell-and-upsell-products # Display cross-sell and upsell products Fetch related products and clusters linked to the current product for a "You might also need" section. ## Query ```graphql query GetCrossUpsell($productId: Int!, $language: String) { product(productId: $productId) { crossupsellsFrom(input: { offset: 12, page: 1 }) { items { type productTo { ... on Product { productId names(language: $language) { value } price { gross net } media { images(search: { sort: ASC, offset: 1 }) { items { imageVariants( input: { transformations: { name: "thumb" transformation: { width: 200 height: 200 fit: BOUNDS format: WEBP } } } ) { url } } } } } ... on Cluster { clusterId names(language: $language) { value } defaultProduct { productId price { gross net } } } } } itemsFound } } } ``` ## Variables ```json { "productId": 123, "language": "EN" } ``` ## Response ```json { "data": { "product": { "crossupsellsFrom": { "items": [ { "type": "RELATED", "productTo": { "productId": 26, "names": [{ "value": "Dovre Rock350 TB" }], "price": { "gross": 2275.21, "net": 2753.00 } } }, { "type": "ACCESSORIES", "productTo": { "productId": 31, "names": [{ "value": "Dovre Saga 101 heat shield left" }], "price": { "gross": 70.00, "net": 84.70 } } }, { "type": "PARTS", "productTo": { "productId": 28, "names": [{ "value": "Dovre 425CB fire bed" }], "price": { "gross": 172.44, "net": 208.65 } } } ], "itemsFound": 12 } } } } ``` ## How it works Each item has a `type` field describing the relationship: `RELATED`, `ACCESSORIES`, `PARTS`, or `OPTIONS`. Use `crossupsellsFrom` to get products linked from the current product, or `crossupsellsTo` for the reverse direction. The `productTo` field is a union — it can be a `Product` or `Cluster`, so use inline fragments to access type-specific fields. ## See also - [Understanding products and categories](/frontend/domain-guides/products-and-catalog/understanding-products-and-categories) — how clusters and products relate --- ## Display tiered and bulk pricing URL: https://docs.propeller-commerce.com/frontend/recipes/display-tiered-and-bulk-pricing # Display tiered and bulk pricing Show quantity breaks and volume discounts on a product detail page so the buyer sees how the unit price drops. ## Query ```graphql query GetBulkPrices($productId: Int!, $taxZone: String) { product(productId: $productId) { ... on Product { price(input: { taxZone: $taxZone }) { gross net } priceData { bulkPriceDiscountType } bulkPrices { gross net list quantity } } } } ``` ## Variables ```json { "productId": 123, "taxZone": "NL" } ``` ## Response ```json { "data": { "product": { "price": { "gross": 10.00, "net": 12.10 }, "priceData": { "bulkPriceDiscountType": "NET_PRICE" }, "bulkPrices": [ { "gross": 10.00, "net": 12.10, "list": 10.00, "quantity": 1 }, { "gross": 8.50, "net": 10.29, "list": 10.00, "quantity": 10 }, { "gross": 7.00, "net": 8.47, "list": 10.00, "quantity": 50 } ] } } } ``` ## How it works Each entry in `bulkPrices` represents the unit price at a quantity threshold. The `quantity` field is the minimum quantity for that tier — the tier applies from that quantity up to the next tier's `quantity - 1`. The `bulkPriceDiscountType` determines how tiers work: `NET_PRICE` means each tier's `gross`/`net` is the absolute unit price, while `PERCENTAGE` means the value is a discount off the base price. The `list` field always shows the original list price for reference. ## See also - [Tiered and volume pricing](/frontend/domain-guides/pricing-and-discounts/tiered-and-volume-pricing) — full guide on bulk pricing --- ## Fetch cart with totals and tax breakdown URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-cart-with-totals # Fetch cart with totals and tax breakdown Retrieve the full cart including line items, subtotals, shipping costs, tax levels, and the grand total. ## Query ```graphql query GetCart($cartId: String!) { cart(id: $cartId) { cartId items { itemId productId quantity price priceNet totalPrice totalPriceNet } total { subTotal subTotalNet totalGross totalNet discount discountNet } taxLevels { taxPercentage price } postageData { method price priceNet carrier } actionCode } } ``` ## Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` ## Response ```json { "data": { "cart": { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728", "items": [ { "itemId": "019c77b7-72a0-75fe-8a20-b98927d76652", "productId": 25, "quantity": 3, "price": 1595.04, "priceNet": 1929.9984, "totalPrice": 4785.12, "totalPriceNet": 5789.99 } ], "total": { "subTotal": 4785.12, "subTotalNet": 5789.99, "totalGross": 5120.12, "totalNet": 6195.34, "discount": 0, "discountNet": 0 }, "taxLevels": [ { "taxPercentage": 21, "price": 1075.22 } ], "postageData": { "method": "DELIVERY", "price": 300, "priceNet": 363, "carrier": null }, "actionCode": "" } } } ``` ## How it works `total.subTotal` is the sum of all line items excluding tax and shipping. `total.totalGross` adds shipping and payment costs but excludes tax, while `total.totalNet` is the final amount including tax. The `taxLevels` array breaks down the tax per rate — useful for displaying a tax summary when multiple VAT rates apply. The `price` / `priceNet` naming follows Propeller convention: `price` is gross (excluding tax), `priceNet` is net (including tax). ## See also - [Add a product to cart](/frontend/recipes/add-product-to-cart) - [Cart management](/frontend/domain-guides/cart-and-checkout/cart-management) — full cart lifecycle guide --- ## Fetch a category breadcrumb URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-category-breadcrumb # Fetch a category breadcrumb Get the full ancestor chain from root to the current category for breadcrumb navigation. ## Query ```graphql query GetBreadcrumb($categoryId: Float!) { category(categoryId: $categoryId) { categoryId name { language value } categoryPath { categoryId name { language value } slug { language value } } } } ``` ## Variables ```json { "categoryId": 1798 } ``` ## Response ```json { "data": { "category": { "categoryId": 1798, "name": [{ "language": "NL", "value": "2-in-1 Laptops" }], "categoryPath": [ { "categoryId": 17, "name": [{ "language": "NL", "value": "PDM" }], "slug": [{ "language": "NL", "value": "pdm" }] }, { "categoryId": 1794, "name": [{ "language": "NL", "value": "ICT and hardware" }], "slug": [{ "language": "NL", "value": "ict-and-hardware" }] }, { "categoryId": 1795, "name": [{ "language": "NL", "value": "Computers / laptops" }], "slug": [{ "language": "NL", "value": "computers-laptops" }] } ] } } } ``` ## How it works `categoryPath` returns ancestors from root to the immediate parent. It does **not** include the current category itself. Root categories return an empty array. You can pass `hidden: N` to `categoryPath` to exclude hidden ancestors from the breadcrumb. ## See also - [Fetch a category by ID](/frontend/recipes/fetch-category-by-id) - [Build a category navigation tree](/frontend/recipes/build-category-navigation-tree) --- ## Fetch a category by ID URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-category-by-id # Fetch a category by ID Retrieve a single category with its core data. ## Query ```graphql query GetCategory($categoryId: Int!) { category(categoryId: $categoryId) { categoryId name { language value } description { language value } shortDescription { language value } slug { language value } hidden defaultLanguage path } } ``` ## Variables ```json { "categoryId": 1795 } ``` ## Response ```json { "data": { "category": { "categoryId": 1795, "name": [ { "language": "NL", "value": "Computers / laptops" } ], "description": [ { "language": "NL", "value": "Zakelijke computers en laptops
" } ], "shortDescription": [ { "language": "NL", "value": "Computers en laptops" } ], "slug": [ { "language": "NL", "value": "computers-laptops" } ], "hidden": "N", "defaultLanguage": "NL", "path": "100035/100092" } } } ``` ## How it works The `path` field contains internal IDs (not categoryIds) separated by slashes, representing the path from root to this category. The `hidden` parameter on the query acts as a filter: `hidden: N` returns the category only if it is visible, `hidden: Y` only if hidden, omitted returns it regardless. ## See also - [Fetch a category breadcrumb](/frontend/recipes/fetch-category-breadcrumb) - [Build a category navigation tree](/frontend/recipes/build-category-navigation-tree) --- ## Fetch a cluster with options URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-cluster-with-options # Fetch a cluster with options Retrieve a cluster with its option groups (add-ons like monitors, warranty plans or accessories). ## Query ```graphql query GetClusterOptions($clusterId: Int!, $taxZone: String!) { cluster(clusterId: $clusterId) { clusterId names { language value } options { clusterOptionId names { language value } isRequired hidden defaultProduct { productId names { language value } } products { productId sku names { language value } price(input: { taxZone: $taxZone }) { gross net } } } } } ``` ## Variables ```json { "clusterId": 30, "taxZone": "NL" } ``` ## Response ```json { "data": { "cluster": { "clusterId": 30, "names": [{ "language": "NL", "value": "Dell Optiplex 7010 bundel" }], "options": [ { "clusterOptionId": 31, "names": [{ "language": "NL", "value": "Monitor" }], "isRequired": "Y", "hidden": "N", "defaultProduct": { "productId": 890, "names": [{ "language": "NL", "value": "Dell P2422H 24\"" }] }, "products": [ { "productId": 890, "sku": "DELL-P2422H", "names": [{ "language": "NL", "value": "Dell P2422H 24\"" }], "price": { "gross": 249, "net": 301.29 } }, { "productId": 891, "sku": "DELL-P2723QE", "names": [{ "language": "NL", "value": "Dell P2723QE 27\" 4K" }], "price": { "gross": 449, "net": 543.29 } } ] }, { "clusterOptionId": 32, "names": [{ "language": "NL", "value": "Garantie" }], "isRequired": "Y", "hidden": "N", "defaultProduct": null, "products": [ { "productId": 895, "sku": "SVC-3Y-NEXT", "names": [{ "language": "NL", "value": "3 jaar next business day" }], "price": { "gross": 89, "net": 107.69 } }, { "productId": 896, "sku": "SVC-5Y-NEXT", "names": [{ "language": "NL", "value": "5 jaar next business day" }], "price": { "gross": 159, "net": 192.39 } } ] } ] } } } ``` ## How it works Options are add-on product groups. When `isRequired` is `Y`, the customer must select a product from that option to complete the configuration. Products inside an option have their `clusterId` set to the `clusterOptionId`, not the parent cluster's ID. ## See also - [Fetch a cluster with its products](/frontend/recipes/fetch-cluster-with-products) - [Fetch a cluster with variant selection config](/frontend/recipes/fetch-cluster-with-variant-config) --- ## Fetch a cluster with its products URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-cluster-with-products # Fetch a cluster with its products Retrieve a cluster and all its variant products. ## Query ```graphql query GetCluster($clusterId: Int!, $taxZone: String!) { cluster(clusterId: $clusterId) { clusterId names { language value } descriptions { language value } categoryId hidden defaultProduct { productId names { language value } } products { productId sku names { language value } status orderable price(input: { taxZone: $taxZone }) { gross net } } } } ``` ## Variables ```json { "clusterId": 30, "taxZone": "NL" } ``` ## Response ```json { "data": { "cluster": { "clusterId": 30, "names": [{ "language": "NL", "value": "Dell Optiplex 7010 serie" }], "descriptions": [{ "language": "NL", "value": "Zakelijke desktop-pc reeks
" }], "categoryId": 1796, "hidden": "N", "defaultProduct": { "productId": 880, "names": [{ "language": "NL", "value": "Dell Optiplex 7010 - i5 / 16GB / 512GB" }] }, "products": [ { "productId": 880, "sku": "DELL-7010-I5-16", "names": [{ "language": "NL", "value": "Dell Optiplex 7010 - i5 / 16GB / 512GB" }], "status": "A", "orderable": "Y", "price": { "gross": 749, "net": 906.29 } }, { "productId": 881, "sku": "DELL-7010-I7-32", "names": [{ "language": "NL", "value": "Dell Optiplex 7010 - i7 / 32GB / 1TB" }], "status": "A", "orderable": "Y", "price": { "gross": 1149, "net": 1390.29 } } ] } } } ``` ## How it works Each product in a cluster has its own independent price — there is no price inheritance from the cluster level. The `products` field is not paginated. Products inside the cluster have `categoryId: 0` and inherit their category from the cluster's `categoryId`. ## See also - [Fetch a cluster with variant selection config](/frontend/recipes/fetch-cluster-with-variant-config) - [Fetch a cluster with options](/frontend/recipes/fetch-cluster-with-options) --- ## Fetch a cluster with variant selection config URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-cluster-with-variant-config # Fetch a cluster with variant selection config Retrieve a cluster with its ClusterConfig to build a variant selector (e.g. processor dropdown, memory selector). ## Query ```graphql query GetClusterVariants($clusterId: Int!) { cluster(clusterId: $clusterId) { clusterId names { language value } config { id name settings { id name displayType priority } } drillDowns { attributeId priority displayType } products { productId names { language value } attributes(input: { attributeDescription: { names: ["PROCESSOR", "RAM_GB"] } }) { items { attributeDescription { name } value { ... on AttributeTextValue { textValues { language values } } ... on AttributeIntValue { intValue } } } } } } } ``` ## Variables ```json { "clusterId": 30 } ``` ## Response ```json { "data": { "cluster": { "clusterId": 30, "names": [{ "language": "NL", "value": "Dell Optiplex 7010 serie" }], "config": { "id": 8114, "name": "DESKTOP_CONFIG", "settings": [ { "id": 101, "name": "PROCESSOR", "displayType": "DROPDOWN", "priority": 0 }, { "id": 102, "name": "RAM_GB", "displayType": "DROPDOWN", "priority": 1 } ] }, "drillDowns": [ { "attributeId": "abc-123", "priority": 0, "displayType": "DROPDOWN" }, { "attributeId": "def-456", "priority": 1, "displayType": "DROPDOWN" } ], "products": [ { "productId": 880, "names": [{ "language": "NL", "value": "Dell Optiplex 7010 - i5 / 16GB / 512GB" }], "attributes": { "items": [ { "attributeDescription": { "name": "PROCESSOR" }, "value": { "textValues": [{ "language": "NL", "values": ["Intel Core i5-13500"] }] } }, { "attributeDescription": { "name": "RAM_GB" }, "value": { "intValue": 16 } } ] } }, { "productId": 881, "names": [{ "language": "NL", "value": "Dell Optiplex 7010 - i7 / 32GB / 1TB" }], "attributes": { "items": [ { "attributeDescription": { "name": "PROCESSOR" }, "value": { "textValues": [{ "language": "NL", "values": ["Intel Core i7-13700"] }] } }, { "attributeDescription": { "name": "RAM_GB" }, "value": { "intValue": 32 } } ] } } ] } } } ``` ## How it works The `config.settings` array defines which attributes the frontend uses for variant selection. `priority` controls display order (lower = first). `displayType` tells you how to render each selector: `DROPDOWN` for a select menu, `RADIO` for radio buttons, `COLOR` for color swatches, `IMAGE` for image swatches. When a customer selects values for all settings, match against product attributes to find the corresponding product. ## See also - [Fetch a cluster with its products](/frontend/recipes/fetch-cluster-with-products) - [Fetch a cluster with options](/frontend/recipes/fetch-cluster-with-options) --- ## Fetch customer addresses URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-customer-addresses # Fetch customer addresses Retrieve all delivery addresses for a company, useful for the address book and checkout address selector. ## Query ```graphql query GetCompanyAddresses($companyId: Float!, $type: AddressType) { addressesByCompanyId(companyId: $companyId, type: $type) { id firstName lastName company street number numberExtension postalCode city country isDefault type name phone email } } ``` ## Variables ```json { "companyId": 141, "type": "delivery" } ``` ## Response ```json { "data": { "addressesByCompanyId": [ { "id": 81166, "firstName": "Philippe", "lastName": "Barril", "company": "ASML Veldhoven", "street": "De Run", "number": "6501", "numberExtension": null, "postalCode": "5504 DR", "city": "Veldhoven", "country": "NL", "isDefault": "N", "type": "delivery", "name": "", "phone": null, "email": null }, { "id": 82101, "firstName": "Andre", "lastName": "Vries", "company": "ASML Veldhoven", "street": "De Run", "number": "1120", "numberExtension": null, "postalCode": "5503 LA", "city": "Veldhoven", "country": "NL", "isDefault": "Y", "type": "delivery", "name": "", "phone": null, "email": null } ] } } ``` ## How it works Pass the `companyId` from the logged-in user's profile. The optional `type` argument filters by address type: use `delivery` or `invoice`. Omit it to get all addresses. The `isDefault` field indicates which address should be pre-selected in the checkout. The `id` field can be used to populate the cart address via `cartUpdateAddress`. There is also `addressesByCustomerId` for B2C customers and `addressesByUserId` for direct user lookups. ## See also - [Get the logged-in user](/frontend/recipes/get-logged-in-user) - [Set addresses on the cart](/frontend/recipes/set-cart-addresses) --- ## Fetch favorite lists with products URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-favorite-lists # Fetch favorite lists with products Retrieve the user's favorite (wish) lists with their products. ## Query ```graphql query GetFavoriteLists($offset: Int, $page: Int) { favoriteLists(input: { offset: $offset, page: $page }) { items { id name isDefault slug createdAt products(input: { offset: 5 }) { items { ... on Product { productId names(language: "NL") { value } sku } } itemsFound } } itemsFound } } ``` ## Variables ```json { "offset": 10, "page": 1 } ``` ## Response ```json { "data": { "favoriteLists": { "items": [ { "id": "66a21b15dd2e049b327c6434", "name": "Michurin studio center", "isDefault": false, "slug": "michurin-studio-center", "createdAt": "2024-07-25T09:29:57.242Z", "products": { "items": [ { "productId": 39, "names": [{ "value": "HP ProBook 450 G8 Notebook 39.6 cm Full HD Intel® Windows 10 Pro Silver" }], "sku": "203F7EA#ABH" } ], "itemsFound": 1 } }, { "id": "66d6ab6e590599fecc3fa5a2", "name": "IT supplies afdeling management", "isDefault": false, "slug": "it-supplies-afdeling-management", "createdAt": "2024-09-03T06:23:42.983Z", "products": { "items": [ { "productId": 41, "names": [{ "value": "Acer Aspire 3 Spin Laptop A3SP14-31PT | Zilver" }], "sku": "NX.KENEH.007" }, { "productId": 44, "names": [{ "value": "Logitech MX Mechanical Wireless Keyboard + Logitech MX Master 3S" }], "sku": "908468" } ], "itemsFound": 5 } } ], "itemsFound": 18 } } } ``` ## How it works Favorite lists are scoped to the authenticated user's contact or company. Each list has a nested `products` connection that supports its own pagination via the `input` argument. The `isDefault` field indicates which list is the primary one. Products inside favorite lists are returned as the `Product` union type, so use an inline fragment (`... on Product`) to access product fields. Lists can also contain clusters — use `... on Cluster` for those. ## See also - [Add or remove a product from a favorite list](/frontend/recipes/manage-favorite-list-items) --- ## Fetch quote or order detail URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-order-detail # Fetch quote or order detail Retrieve the full details of an order or quote request, including line items, totals, and addresses. ## Query ```graphql query GetOrderDetail($orderId: Int!) { order(orderId: $orderId) { id status type reference remarks email createdAt items { id productId name sku quantity price priceTotal priceNet priceTotalNet taxPercentage } total { gross net tax taxPercentages { percentage total } } addresses(type: invoice) { firstName lastName company street number postalCode city country } postageData { method gross net taxPercentage carrier } } } ``` ## Variables ```json { "orderId": 2791 } ``` ## Response ```json { "data": { "order": { "id": 2791, "status": "REQUEST", "type": "dropshipment", "reference": null, "remarks": null, "email": "mark+merford@propel.us", "createdAt": "2026-02-12T13:09:16.142Z", "items": [ { "id": 26771, "productId": 1877, "name": "Rvs 316 kogelafsluiter inw/inw. 2-delig volle doorlaat", "sku": "2440-0280-1", "quantity": 3, "price": 37.41, "priceTotal": 112.23, "priceNet": 37.41, "priceTotalNet": 112.23, "taxPercentage": 0 } ], "total": { "gross": 917.23, "net": 917.23, "tax": 0, "taxPercentages": [ { "percentage": 0, "total": 0 } ] }, "addresses": [ { "firstName": "Mark", "lastName": "Egmond", "company": "Merford Cabins B.V.", "street": "Industrieweg", "number": "16", "postalCode": "4283 GZ", "city": "Giessen", "country": "NL" } ], "postageData": { "method": "REGULAR", "gross": 750, "net": 750, "taxPercentage": 0, "carrier": null } } } } ``` ## How it works The `order` query accepts either `orderId` (integer) or `orderUUID` (string). It works for both regular orders and quote requests — check the `type` and `status` fields to distinguish them. The `addresses` field accepts an optional `type` argument to filter by `invoice` or `delivery`. Order totals use `gross` (excluding tax) and `net` (including tax), which differs from the cart's naming (`totalGross` / `totalNet`). The `taxPercentages` array breaks down the tax per rate. ## See also - [Fetch quote list](/frontend/recipes/fetch-quote-list) - [Convert a quote into an order](/frontend/recipes/convert-quote-to-order) --- ## Fetch order history with status filtering URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-order-history-with-status-filtering # Fetch order history with status filtering Query past orders for the logged-in contact, filtered by status with pagination. ## Query ```graphql query GetOrders($status: [String!], $page: Int, $offset: Int) { orders(input: { status: $status, page: $page, offset: $offset }) { items { id status createdAt reference total { gross net tax } items { id productId name quantity } } itemsFound page pages } } ``` ## Variables ```json { "status": ["NEW", "CONFIRMED"], "page": 1, "offset": 10 } ``` ## Response ```json { "data": { "orders": { "items": [ { "id": "ord-12345", "status": "CONFIRMED", "createdAt": "2025-01-15T10:30:00.000Z", "reference": "PO-2025-0042", "total": { "gross": 1250.00, "net": 1512.50, "tax": 262.50 }, "items": [ { "id": "item-1", "productId": 1895, "name": "HP ProBook 450 G10 i5", "quantity": 1 } ] } ], "itemsFound": 24, "page": 1, "pages": 3 } } } ``` ## How it works Available filters: `status`, `createdAt`, `price`, `userId`, `companyIds`, `type` (e.g., `dropshipment`, `purchase`, `quotation`). You can filter by date range with `createdAt: { greaterThan: "2024-01-01", lessThan: "2024-12-31" }` or by company with `companyIds: [678]` for B2B use cases. ## See also - [Order history](/frontend/domain-guides/orders-and-shipments/order-history) — full guide on order history, details and filtering --- ## Fetch available payment methods URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-payment-methods # Fetch available payment methods Retrieve the list of payment methods available for the current cart. ## Query ```graphql query GetPaymentMethods($cartId: String!) { cart(id: $cartId) { payMethods { name code externalCode type } } } ``` ## Variables ```json { "cartId": "019c77b7-565f-7ea0-8660-42bc63a7f728" } ``` ## Response ```json { "data": { "cart": { "payMethods": [ { "name": "On pickup", "code": "AFHALEN", "externalCode": null, "type": "EUR" }, { "name": "Visa", "code": "CREDITCARD", "externalCode": null, "type": "EUR" }, { "name": "Mastercard", "code": "MASTERCARD", "externalCode": null, "type": "EUR" }, { "name": "PayPal", "code": "PAYPAL", "externalCode": null, "type": "EUR" }, { "name": "On account", "code": "REKENING", "externalCode": null, "type": "EUR" } ] } } } ``` ## How it works The available payment methods depend on the cart context — the logged-in user, their company, and business rules may filter the list. Use the `code` value when setting the payment method on the cart via `cartUpdate`. The `externalCode` maps to your payment service provider's method identifier when a PSP integration is configured. Display the `name` field to the user in the checkout UI. ## See also - [Fetch available shipping methods](/frontend/recipes/fetch-shipping-methods) - [Place an order from the cart](/frontend/recipes/place-order) --- ## Fetch product attributes as specifications URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-product-attributes # Fetch product attributes as specifications Retrieve a product's attributes to display as a specifications table on the product detail page. ## Query ```graphql query GetProductAttributes($productId: Int!, $language: String) { product(productId: $productId) { productId names(language: $language) { value } attributes(input: { offset: 50 }) { items { attributeDescription { name descriptions { language value } type group } value { ... on AttributeTextValue { textValues { language values } } ... on AttributeEnumValue { enumValues } ... on AttributeIntValue { intValue } ... on AttributeDecimalValue { decimalValue } ... on AttributeColorValue { colorValue } } } itemsFound } } } ``` ## Variables ```json { "productId": 25, "language": "NL" } ``` ## Response ```json { "data": { "product": { "productId": 25, "names": [{ "value": "Dovre SAGA 1001" }], "attributes": { "items": [ { "attributeDescription": { "name": "ONDERSTEL_KLEUR", "descriptions": [{ "language": "NL", "value": "Kleur van het onderstel (frame)" }], "type": "COLOR", "group": "Kantoormeubilair" }, "value": { "colorValue": "#FFFFFF" } }, { "attributeDescription": { "name": "RUO_STATEMENT", "descriptions": [ { "language": "EN", "value": "Research Use Only statement" }, { "language": "NL", "value": "Research Use Only statement" } ], "type": "TEXT", "group": "RPIP" }, "value": { "textValues": [ { "language": "EN", "values": [] }, { "language": "NL", "values": null } ] } } ], "itemsFound": 96 } } } } ``` ## How it works The `value` field is a union type — use inline fragments to handle each attribute type: `AttributeTextValue` for text, `AttributeEnumValue` for dropdowns, `AttributeIntValue` and `AttributeDecimalValue` for numbers, and `AttributeColorValue` for colors. The `attributeDescription.descriptions` array contains the human-readable label per language, which you display as the specification name. Group attributes by the `group` field to create specification sections. Filter out attributes with empty or null values before rendering. ## See also - [Fetch a product with pricing](/frontend/recipes/fetch-product-with-pricing) - [Search products with faceted filters](/frontend/recipes/search-products-with-faceted-filters) --- ## Fetch a product by ID URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-product-by-id # Fetch a product by ID Retrieve a single product using its `productId`. ## Query ```graphql query GetProduct($productId: Int!) { product(productId: $productId) { productId sku names { language value } descriptions { language value } shortDescriptions { language value } manufacturer supplier status orderable hidden categoryId containerClass clusterId } } ``` ## Variables ```json { "productId": 1895 } ``` ## Response ```json { "data": { "product": { "productId": 1895, "sku": "HP-450G10-I5", "names": [ { "language": "NL", "value": "HP ProBook 450 G10 i5" }, { "language": "EN", "value": "HP ProBook 450 G10 i5" } ], "descriptions": [ { "language": "NL", "value": "Zakelijke laptop met 15.6 inch scherm
" } ], "shortDescriptions": [ { "language": "NL", "value": "15.6\" i5 laptop" } ], "manufacturer": "HP", "supplier": "Ingram Micro", "status": "A", "orderable": "Y", "hidden": "N", "categoryId": 1795, "containerClass": "PRODUCT", "clusterId": null } } } ``` ## How it works Use `productId` for lookups. The deprecated `id` field still works but should be avoided. If the product does not exist, the API returns `null`. Products that belong to a cluster have a `clusterId` set and `containerClass: "CLUSTER"`. ## See also - [Fetch a product by SKU](/frontend/recipes/fetch-product-by-sku) - [Fetch a product by slug](/frontend/recipes/fetch-product-by-slug) --- ## Fetch a product by SKU URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-product-by-sku # Fetch a product by SKU Retrieve a product using its SKU when you don't have the `productId`. ## Query ```graphql query GetProductBySku($sku: String!) { product(sku: $sku) { productId sku names { language value } manufacturer status } } ``` ## Variables ```json { "sku": "HP-450G10-I5" } ``` ## Response ```json { "data": { "product": { "productId": 1895, "sku": "HP-450G10-I5", "names": [ { "language": "NL", "value": "HP ProBook 450 G10 i5" } ], "manufacturer": "HP", "status": "A" } } } ``` ## How it works SKU is a unique identifier. If no product matches the SKU, the API returns `null`. ## See also - [Fetch a product by ID](/frontend/recipes/fetch-product-by-id) - [Fetch a product by slug](/frontend/recipes/fetch-product-by-slug) --- ## Fetch a product by slug URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-product-by-slug # Fetch a product by slug Retrieve a product using its URL-friendly slug. Useful when resolving storefront URLs. ## Query ```graphql query GetProductBySlug($slug: String!, $language: String!) { product(slug: $slug, language: $language) { productId sku names { language value } slugs { language value } } } ``` ## Variables ```json { "slug": "hp-probook-450", "language": "NL" } ``` ## Response ```json { "data": { "product": { "productId": 1895, "sku": "HP-450G10-I5", "names": [ { "language": "NL", "value": "HP ProBook 450 G10 i5" } ], "slugs": [ { "language": "NL", "value": "hp-probook-450" } ] } } } ``` ## How it works Slugs can differ per language. Pass the `language` variable to match the correct localized slug. If you omit `language`, the system matches against the default language. ## See also - [Fetch a product by ID](/frontend/recipes/fetch-product-by-id) - [Fetch a product by SKU](/frontend/recipes/fetch-product-by-sku) --- ## Fetch product documents URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-product-documents # Fetch product documents Retrieve downloadable documents (PDFs, datasheets, manuals) attached to a product. ## Query ```graphql query GetProductDocuments($productId: Int!) { product(productId: $productId) { productId media { documents { items { id type priority alt(language: "NL") { language value } description(language: "NL") { language value } documents { language originalUrl mimeType } } itemsFound } } } } ``` ## Variables ```json { "productId": 25 } ``` ## Response ```json { "data": { "product": { "productId": 25, "media": { "documents": { "items": [ { "id": "c277d06a-5bed-4bd6-809b-d833387447cc", "type": "DOCUMENT", "priority": 1000, "alt": [{ "language": "NL", "value": "Brochure-Dovre-Saga-.pdf" }], "description": [{ "language": "NL", "value": "Brochure-Dovre-Saga-.pdf" }], "documents": [ { "language": "en", "originalUrl": "https://media.helice.cloud/newbo.helice.cloud/documents/en/c277d06a-5bed-4bd6-809b-d833387447cc-Brochure-Dovre-Saga-.pdf", "mimeType": "application/pdf" } ] }, { "id": "44225fc6-3dae-439a-86e8-35480163aa51", "type": "DOCUMENT", "priority": 1000, "alt": [{ "language": "NL", "value": "Installatievoorschriften.pdf" }], "description": [{ "language": "NL", "value": "Installatievoorschriften.pdf" }], "documents": [ { "language": "en", "originalUrl": "https://media.helice.cloud/newbo.helice.cloud/documents/en/44225fc6-3dae-439a-86e8-35480163aa51-Installatievoorschriften.pdf", "mimeType": "application/pdf" } ] } ], "itemsFound": 2 } } } } } ``` ## How it works Each `MediaDocument` item contains a nested `documents` array with the actual file URLs per language. Use `originalUrl` to link to the downloadable file. The `description` and `alt` fields provide display names — pass a `language` argument to filter them. The `mimeType` tells you the file format (e.g., `application/pdf`). Sort by `priority` for display order (lower values appear first). The `documents` response is paginated — pass a `search` argument with `page` and `offset` if the product has many documents. ## See also - [Fetch product videos](/frontend/recipes/fetch-product-videos) - [Get product images with transformations](/frontend/recipes/get-product-images-with-transformations) --- ## Fetch product stock across warehouses URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-product-stock # Fetch product stock across warehouses Check real-time inventory for a product, broken down by warehouse location, including supplier stock and next delivery date. ## Query ```graphql query GetProductStock($productId: Int!) { product(productId: $productId) { productId inventory { totalQuantity localQuantity supplierQuantity nextDeliveryDate balance { quantity warehouseId } } } } ``` ## Variables ```json { "productId": 12345 } ``` ## Response ```json { "data": { "product": { "productId": 12345, "inventory": { "totalQuantity": 340, "localQuantity": 200, "supplierQuantity": 140, "nextDeliveryDate": "2026-03-01", "balance": [ { "quantity": 120, "warehouseId": 1 }, { "quantity": 80, "warehouseId": 2 } ] } } } } ``` ## How it works `totalQuantity` is the sum of `localQuantity` (your own warehouses) and `supplierQuantity` (external suppliers). The `balance` array breaks stock down per warehouse so you can show regional availability or route orders to the nearest fulfillment location. --- ## Fetch product videos URL: https://docs.propeller-commerce.com/frontend/recipes/fetch-product-videos # Fetch product videos Retrieve videos attached to a product for embedding on the product detail page. ## Query ```graphql query GetProductVideos($productId: Int!) { product(productId: $productId) { productId media { videos { items { id type priority description(language: "NL") { language value } videos { language uri mimeType } } itemsFound } } } } ``` ## Variables ```json { "productId": 25 } ``` ## Response ```json { "data": { "product": { "productId": 25, "media": { "videos": { "items": [ { "id": "06ca1458-15aa-4634-a843-c5b7585c4cab", "type": "VIDEO", "priority": 1000, "description": [{ "language": "NL", "value": "SAGA101 - SAGA107" }], "videos": [ { "language": "NL", "uri": "https://www.youtube.com/embed/DJDVrhqLm20", "mimeType": "video/mp4" } ] } ], "itemsFound": 1 } } } } } ``` ## How it works Each `MediaVideo` item contains a nested `videos` array with the video URIs per language. The `uri` field typically contains a YouTube or Vimeo embed URL that you can use directly in an `