Inventory Elf

Developer documentation · API v2 and v1

Inventory Elf GraphQL API

Use POST /api/v2/graphql for new integrations. POST /api/v1/graphql remains supported with its saved-record inventory contract. Both versions share bearer-token authentication, scopes, limits, catalog operations, settings, availability publishing and stock writers. Requests/responses use JSON over HTTPS. Site creation, webhooks and subscriptions are not exposed. Binary image uploads continue at /api/v1/images.

Published schemas: v2 schema and v1 schema. Authenticated introspection follows normal query limits. Prices are decimal strings, IDs are UUIDs, timestamps are ISO 8601 UTC. Stored stock is signed; mutation quantities are nonnegative 32-bit integers, subject to direction and reason validation.

V2 inventory and migration from v1

Every material, component and product variant exists in inventory at every organization site. Products group variants. Missing saved balances return quantity: 0, version: 0, inventoryItemId: null and location: null. Reading inventory, creating catalog items, creating sites or connecting Shopify products creates no stock rows/events. Existing positive, zero and negative stock, labels, settings and immutable history are preserved.

query Inventory($siteId: ID!, $after: String) {
  inventory(siteId: $siteId, first: 50, after: $after) {
    nodes { siteId targetType targetId inventoryItemId quantity location version }
    pageInfo { endCursor hasNextPage }
  }
}

Use { siteId, targetType, targetId } as inventory identity, including after saving. Target types are MATERIAL, COMPONENT, PRODUCT_VARIANT. Single-item reads use inventoryItem(siteId:, targetType:, targetId:). An inaccessible/missing site or catalog target returns null. Inventory at an inaccessible site is an empty connection.

V2 inventory is ordered by target type then target UUID, including default zeros. Use opaque cursors only with the same API version, organization, site and collection. Page size defaults to 50 and is capped at 100. Pagination is a live view; inserts before a cursor may require another full scan. V1 inventory still lists saved rows by UUID.

mutation Receive($input: AdjustInventoryInput!) {
  adjustInventory(input: $input) { siteId targetType targetId inventoryItemId version }
}
{
  "input": {
    "siteId": "REPLACE_WITH_SITE_UUID",
    "targetType": "MATERIAL",
    "targetId": "REPLACE_WITH_MATERIAL_UUID",
    "direction": "INCREASE",
    "reason": "STOCK_RECEIVED",
    "quantity": 10,
    "expectedVersion": 0,
    "idempotencyKey": "v2-receipt-1042-line-1"
  }
}

Every adjustment and location edit requires expectedVersion. Read it first; use zero only when no balance exists. The writer checks the version under the site/target lock. Concurrent first writes with distinct keys yield one success and one VERSION_CONFLICT. The first adjustment creates the balance and records only that adjustment atomically; failed changes leave no row/event. Mutation results contain target identity, nullable saved UUID and resulting version. Re-read inventory for quantity and location.

INCREASE/DECREASE require positive quantities. RECOUNT sets a nonnegative total, including zero, and leaves the item visible. DECREASE with reason REMOVE requires a nonblank note and may create a negative balance; other manual decreases cannot do so. Receipts preserve and reduce existing debt. Integer overflow is rejected.

mutation Shelf($site: ID!, $target: ID!, $version: Int!, $key: String!) {
  updateInventoryLocation(siteId: $site, targetType: MATERIAL, targetId: $target,
    location: "Shelf A", expectedVersion: $version, idempotencyKey: $key) {
    siteId targetType targetId inventoryItemId version
  }
}

A first shelf/bin edit can save a zero balance without a quantity event or alert. location: null clears the label. Options/settings remain independent of balance rows. inventoryHistory(siteId:, targetType:, targetId:) uses immutable target snapshots, including history whose saved inventory row was deleted. History uses the same target-type enum as inventory entries. Supply both target fields or neither to read all site history. History pagination remains UUID-ordered.

Migration steps:

  1. Change the GraphQL endpoint and use the v2 schema. Catalog operations remain compatible.
  2. Replace inventory-row keys with site/type/target keys; retain inventoryItemId only as optional saved-record metadata. Expect many zero-balance entries in lists.
  3. Replace first-row addInventoryItem with a normal adjustment at version zero. Replace stock-row deletion with a recount to zero when that is the intended change.
  4. Move single-item, shelf and history inputs to target identity and supply read versions.
  5. Start fresh pagination cursors and mutation keys when switching API versions.

V2 has no stock assignment or deletion mutations. V1 UUID queries and mutations continue to behave as before; a v1 deletion cannot hide a catalog item from app/v2 inventory. Replay keys share the token namespace across versions, but validation includes API version: a key cannot replay a v1 response through v2 or vice versa. Retry an uncertain request with the same version, key and input. Rollout requires no balance backfill or schema migration.

The remaining inventory examples explicitly labeled v1 document compatibility behavior; authentication, catalog, limits and other shared operations apply to both versions.

Create a token

In the app, open Organization → Settings → API tokens as an organization admin. Enter an integration name, select permissions and choose expiry (default 90 days, maximum 365). Copy the token immediately: it is only shown once. Store it in your integration's secret manager, not source code or URLs.

Only admins of that organization can issue/list/revoke tokens. Tokens are bound to one organization; GraphQL accepts no organization override. A token stops working when expired, revoked, or while its creator is no longer an admin there. Admins may revoke another admin's token. If admin access is restored, an unrevoked, unexpired token becomes valid again; revoke tokens when permanently offboarding an integration. Maximum 20 active tokens and 10 creations per rolling hour per organization. The list shows up to 100 tokens, with active tokens first, then recent expired/revoked tokens. For rotation, create a replacement, deploy it, then revoke the old token. Automatic overlap scheduling is not implemented.

ScopeOperations
catalog:readCatalog records and image URLs, direct composition items, options, categories, units
catalog:writeCreate/update materials, components, component models, products and variants; manage component model connections; upload replacement images
catalog:deleteDelete catalog records
inventory:readSites, inventory and immutable history; v2 includes default-zero catalog targets
inventory:writeAdjust counts and update locations; v1 also adds saved rows
inventory:deleteV1 saved-row removal and stock-linked catalog deletion
inventory-options:readRead per-site minimum levels and alert thresholds
inventory-options:writeSave per-site minimum levels and alert thresholds
availability:readDiscover connected variants and read Availability Sync previews/status
availability:writeCheck live eligible locations, enable/disable per-location sync, retry publishing

Write/delete scopes automatically include the corresponding read scope. Availability and inventory-options permissions also include inventory:read for site, stock and target discovery. Existing tokens gain no new permissions; create a replacement token with the desired scopes. Deleting a catalog record with linked stock additionally requires inventory:delete, including product-to-variant cascades. Tokens never grant member management, credential access, token management or browser login.

First request

Set INVENTORY_ELF_URL to your Inventory Elf service URL (for example, https://invelf.com) and INVENTORY_ELF_API_TOKEN from secure configuration. Do not enable shell tracing with credentials present.

curl "$INVENTORY_ELF_URL/api/v1/graphql" \
  -H "Authorization: Bearer $INVENTORY_ELF_API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"query":"{ sites(first: 50) { nodes { id name } pageInfo { endCursor hasNextPage } } }"}'

All requests support query, optional variables, and optional operationName. Only one operation is executed. HTTP operation arrays/batching and subscriptions are rejected. Cookies cannot authenticate this endpoint. Browsers do not receive cross-origin CORS permission; use a server-side integration.

Find catalog IDs and v1 inventory

query Inventory($siteId: ID!, $after: String) {
  inventory(siteId: $siteId, first: 50, after: $after) {
    nodes {
      id siteId materialId componentId productVariantId
      quantity location version
    }
    pageInfo { endCursor hasNextPage }
  }
}

Each stock row has exactly one target: material, component, or product variant. Use materials, components, products, variants(productId:) and single-record queries to discover catalog IDs. categories and units return existing IDs. productOptions(productId:) and optionValues(optionId:) expose variant option IDs. componentItems(componentId:) and variantItems(variantId:) return direct composition rows; variant items do not include inherited product/option items. Use effectiveVariantItems(variantId:) for the combined recipe shown in the app.

All collections use opaque cursors, default first: 50, maximum 100. Pass pageInfo.endCursor as after while hasNextPage is true. Ordering is UUID ascending, including history (effective variant items sort by source, then UUID). Use createdAt to interpret event time. Cursors are specific to the organization, collection and filters. Pagination is a live view, not a snapshot: concurrent inserts may require a subsequent full scan. No search/sort/filter language or total count is exposed in v1 beyond the explicit schema arguments. Inaccessible single records return null; collections contain only accessible rows.

Effective variant children

effectiveVariantItems(variantId:, first:, after:) requires catalog:read and returns product defaults, children from the variant's selected option values, and children assigned directly to the variant. This also works for variants whose variantItems list is empty because all children are inherited. Unselected option values are excluded. Missing or inaccessible variants return an empty connection.

query VariantChildren($variantId: ID!, $after: String) {
  effectiveVariantItems(variantId: $variantId, first: 100, after: $after) {
    nodes {
      id variantId materialId componentId quantity
      source sourceOptionId sourceOptionValueId sourceLabel
    }
    pageInfo { hasNextPage endCursor }
  }
}

source is PRODUCT, OPTION_VALUE, or VARIANT. Option-source rows identify the option and selected value, with a label such as Color: Red. Material quantities can be fractional. Separate rows that reference the same child remain separate; quantities are not combined or recursively expanded. Use componentItems for a component's own children, and material/component to resolve names and SKUs.

Pages sort by source (OPTION_VALUE, PRODUCT, VARIANT), then row UUID. Use (source, id) as a row identity because direct and inherited tables may contain the same UUID. Cursors are specific to the organization, variant and collection; do not reuse a variantItems cursor. The existing direct-only query is unchanged.

Catalog mutations

Every catalog mutation needs a unique idempotencyKey. Every catalog update and delete needs the record's current expectedVersion. Successful catalog mutations return { id, version }; deletions return a null version. Read the record separately for its full data.

mutation CreateMaterial($input: MaterialInput!, $key: String!) {
  createMaterial(input: $input, idempotencyKey: $key) { id version }
}
{
  "input": { "name": "Aluminum bracket", "sku": "BRACKET-001", "price": "3.50" },
  "key": "erp-material-BRACKET-001-create"
}
ResourceCreateUpdateDelete
MaterialcreateMaterialupdateMaterialdeleteMaterial
ComponentcreateComponentupdateComponentdeleteComponent
ProductcreateProductupdateProductdeleteProduct
VariantcreateVariantupdateVariantdeleteVariant

Material updates require name; optional omitted fields remain unchanged. Use empty strings to clear material description/SKU, and null to clear optional price/unit/category fields. Component updates supply a complete replacement items list (empty means remove all direct items) and follow the app's existing field replacement rules. Each component item specifies either materialId or childComponentId and a positive integer quantity. Nested loops are rejected.

Product creation may supply options and values with client-generated UUIDs. The app generates all option combinations, or a single default variant for a product without options. The API caps generated variants at 100 per create. Product updates change name, description and base price; option-definition editing and inherited-item editing are not exposed in this release.

createVariant(productId:, input:) adds one missing combination of the product's existing option values; it does not modify options or generate duplicate combinations. selectedOptionValueIds must select one value per option. Variant creation/update supply a complete direct items list, and each item references one material or component. Component quantities are integers; material quantities may be fractional. Existing SKU, pricing, reference and organization constraints apply. At most 100 elements per input list and eight input nesting levels.

Deletion requires confirm: true. A component referenced by another component must first be removed from that composition. The last variant of a product cannot be deleted individually; delete the product instead. Product deletion removes its variants. Inspect dependencies and stock before deleting catalog rows.

Catalog GraphQL mutations preserve existing images. Use the image upload endpoint below to assign or replace an image after creating an item.

Item images

Materials, components, products and variants expose an optional image field in both single-record and collection queries. image is null when no image is available. Variants use their own image when set, otherwise their product's image.

query MaterialImage($id: ID!) {
  material(id: $id) {
    id name version
    image { url thumbnailUrl largeUrl }
  }
}

The URLs are ready to display or download. They do not require a bearer token; anyone with an image URL can view it. Treat catalog images as public assets. Image URLs may serve optimized formats depending on the requesting client.

Upload or replace an image

Send raw file bytes to POST /api/v1/images with the same bearer token used for GraphQL. The token must include catalog:write. Supply these query parameters:

ParameterValue
targetTypeMATERIAL, COMPONENT, COMPONENT_TEMPLATE, PRODUCT, or VARIANT
targetIdUUID of an existing item in the token's organization
expectedVersionCurrent item version from a GraphQL read

Set Content-Type to image/jpeg, image/png, or image/webp, and send a unique Idempotency-Key header. Do not send JSON, base64, multipart form data, an image URL, or a storage identifier. Uploads accept a single-frame image up to 4 MiB (4,194,304 bytes) and 25 megapixels. SVG, GIF and animated images are not supported. Images are optimized, stripped of metadata, and resized to fit within 4096 × 4096 pixels while preserving their aspect ratio.

curl "$INVENTORY_ELF_URL/api/v1/images?targetType=MATERIAL&targetId=$MATERIAL_ID&expectedVersion=3" \
  -H "Authorization: Bearer $INVENTORY_ELF_API_TOKEN" \
  -H 'Content-Type: image/png' \
  -H 'Idempotency-Key: material-photo-2026-09-12' \
  --data-binary @material.png

Success returns HTTP 200 with { "data": { "id": "…", "version": 4, "image": { "url": "…", "thumbnailUrl": "…", "largeUrl": "…" } } }. Save the returned version for subsequent edits. The upload replaces only the item's image, preserving its other fields. A product upload changes its featured image; a variant upload sets an override. Replacing a product image also changes what variants without their own images display; re-read those variants to see the new URLs. Removing an image or clearing a variant override is not exposed in this version.

Retries must use the same target, expected version, content type, file bytes and idempotency key. Successful retries return the original result without another replacement. Different content with the same key returns IDEMPOTENCY_CONFLICT. A stale version returns VERSION_CONFLICT before the image is replaced. A failed upload leaves the current image unchanged. Uploads use the same token-wide idempotency namespace as GraphQL mutations.

Each upload consumes one request, one write and 100 query-cost points from the ordinary API allowances, including retries. Additional upload limits replenish at 6/minute per token (burst 2) and 30/minute per organization (burst 5). Rejected uploads also consume these allowances once authorized. Honor Retry-After on HTTP 429. Image upload errors use the same errors envelope and request IDs as GraphQL, with HTTP status codes: 400 invalid image or parameters, 401 invalid token, 403 missing permission, 404 inaccessible item, 408 upload timeout, 409 version/idempotency conflict, 413 file too large, 415 unsupported content type, 429 rate limit, or 503 temporary unavailability. A server error may return 500. Check HTTP status and the response body before treating an upload as successful.

V1 add stock and adjust counts

Use addInventoryItem(input:) for the first row at a site, with siteId, targetType (MATERIAL, COMPONENT, PRODUCT_VARIANT), targetId, quantity, optional location/note and idempotencyKey. Initial quantity creates an inventory recount event. A duplicate site/target row is a conflict.

mutation AdjustStock($input: AdjustInventoryInput!) {
  adjustInventory(input: $input) { id version }
}
{
  "input": {
    "inventoryItemId": "REPLACE_WITH_INVENTORY_UUID",
    "direction": "INCREASE",
    "quantity": 10,
    "reason": "STOCK_RECEIVED",
    "note": "Receipt PO-1042",
    "expectedVersion": 3,
    "idempotencyKey": "warehouse-PO-1042-line-1"
  }
}

INCREASE and DECREASE require a positive quantity; RECOUNT sets the absolute count and permits zero. Negative resulting stock and integer overflow are rejected. Every adjustment atomically updates stock and appends an immutable history event. Concurrent browser and API changes increment version.

Use updateInventoryLocation with a version/key to set a location (null clears it). Use deleteInventoryItem with confirm: true, a nonblank note, a version and key to remove a row. Removal records its count going to zero and retains history. inventoryHistory(siteId:, inventoryItemId:) can be filtered by a live inventory ID. After removal, query site history; deleted entries no longer have an inventory ID. History includes target snapshots and event type.

Retries and concurrency

Keys are 1–128 characters: letters, digits, _, ., :, -. Scope is per token across all mutations. The same key, operation and input replay the original result without another write, even if the old expected version is now stale. Different input with the same key returns IDEMPOTENCY_CONFLICT. Failed mutations make no changes and do not reserve a key. Successful replay records are retained for the token lifetime (at least 24 hours unless the token/organization is removed). Changing tokens also changes the idempotency namespace: do not blindly repeat an uncertain mutation after rotating credentials.

On VERSION_CONFLICT, read fresh state, reconcile your desired change, and send a new key with the current version. Avoid blindly recalculating/retrying recounts. A successful mutation saves its result and history together, so retrying the same request does not apply the change twice.

Rate limits and bounded query cost

Limits apply per token and per organization. Request allowances replenish continuously at the rates below. Short bursts are allowed up to the stated capacity. All tokens belonging to an organization share its allowance.

LimitSustained rateBurst capacity
Requests per token120/min20
Requests per organization600/min100
Writes per token30/min5
Writes per organization120/min20
Query cost per token10,000 points/min10,000
Query cost per organization50,000 points/min50,000

Requests must fit within all applicable limits. Invalid GraphQL requests with a valid token still consume request quota; valid operations also consume cost and write budgets before execution, including replays and failed mutations. Service-wide traffic protection may also temporarily limit requests.

Maximum operation cost: 1,000 points; field depth: 8; aliases: 20; parsed tokens: 10,000; request body: 256 KB. Only one root mutation is allowed per request, including aliases and fragment expansion. Cost starts at one per selected field, multiplies descendants of paginated collections by first, and charges root mutations 100 points. Fragments are expanded and directives are counted conservatively even if skipped. Oversized operations are rejected before execution.

Responses include X-Query-Cost after successful analysis and X-Request-Id. 429 includes Retry-After in seconds. Honor it and use exponential backoff with jitter. There is no remaining-quota header in v1. Temporary service unavailability returns 503; use the same idempotency key when retrying uncertain writes.

Errors

Authentication/transport/validation failures use appropriate HTTP status codes (401, 405, 413, 415, 400, 429, 503). Resolver errors follow GraphQL semantics: HTTP 200 with an errors array; always inspect it, even after an HTTP success. Queries can return partial data. Mutation execution is atomic.

{
  "data": null,
  "errors": [{
    "message": "Resource changed. Read its current version before retrying.",
    "path": ["adjustInventory"],
    "extensions": { "code": "VERSION_CONFLICT", "requestId": "..." }
  }]
}

Stable codes include UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, VALIDATION_FAILED, GRAPHQL_PARSE_FAILED, GRAPHQL_VALIDATION_FAILED, CONFLICT, VERSION_CONFLICT, IDEMPOTENCY_CONFLICT, QUERY_TOO_COMPLEX, RATE_LIMITED, PAYLOAD_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE, METHOD_NOT_ALLOWED, SERVICE_UNAVAILABLE, and INTERNAL_SERVER_ERROR. Use the response request ID when reporting an error to support.

API versioning

This reference describes API v1. Breaking changes require a new API version. New fields and operations may be added compatibly.

Minimum levels and alerts

inventoryOptions and saveInventoryOptions share the app's per-site settings for MATERIAL, COMPONENT, and PRODUCT_VARIANT targets, including untracked targets. Unconfigured targets return empty alerts, minimum 0, enforcement false, version 0. Alert thresholds must be unique nonnegative 32-bit integers (maximum 50); saved levels are sorted descending. Saving replaces all settings. Use an empty alerts array to clear alerts and stopSellingAtMinimum: false to disable the reserve. Use the returned version on the next save; stale versions return CONFLICT. Settings changes do not send alerts immediately. Future stock decreases crossing thresholds use the existing notification flow. Enabled minimums affect Shopify publishing without rejecting physical stock changes or already-placed purchases.

query Options($site: ID!, $target: ID!) {
  inventoryOptions(siteId: $site, targetType: MATERIAL, targetId: $target) {
    alertLevels minimumLevel stopSellingAtMinimum version
  }
}
mutation SaveOptions($input: InventoryOptionsInput!) {
  saveInventoryOptions(input: $input) {
    alertLevels minimumLevel stopSellingAtMinimum version
  }
}

Example variables for the mutation:

{"input":{"siteId":"SITE_UUID","targetType":"MATERIAL","targetId":"MATERIAL_UUID","alertLevels":[20,10],"minimumLevel":5,"stopSellingAtMinimum":true,"expectedVersion":0,"idempotencyKey":"material-options-1"}}

Availability Sync to Shopify

Use availabilitySyncVariants to discover connected Shopify variant UUIDs and corresponding Inventory Elf variant UUIDs. availabilitySync(shopifyVariantId:) returns calculated quantities, blockers, per-location enablement, queue/error state and an ISO timestamp version. An inaccessible/unlinked variant returns linked: false. Shopify location IDs are Shopify GIDs, not Inventory Elf UUIDs. availabilitySyncEligibleLocations performs live validation and requires availability:write; ordinary status reads require only availability:read. Connection setup and location mapping remain in the app. Credentials, internal publication payloads and notification delivery records are never public fields.

query Sync($variant: ID!) {
  availabilitySync(shopifyVariantId: $variant) {
    linked runtimeReady enabled version pending error attempts attemptLimit
    locations { locationId siteId enabled finished buildable minimum quantity blockers }
  }
}
mutation SetSync($input: AvailabilitySyncInput!) {
  setAvailabilitySync(input: $input) {
    enabled
    confirmation { token refreshed changes { locationId siteName currentQuantity quantity } }
  }
}
mutation RetrySync($variant: ID!, $key: String!) {
  retryAvailabilitySync(shopifyVariantId: $variant, idempotencyKey: $key) { queued }
}

To enable a location, send its Shopify variant UUID, location GID, enabled: true, the preview's timestamp as expectedVersion, and a unique idempotencyKey. If live quantities differ, the result contains a confirmation and makes no configuration change. Review every quantity, then submit the same request with confirmationToken and a new idempotency key. Live/local quantities are checked again; changed quantities return a refreshed confirmation requiring another review and new key. Reusing a key with changed input returns IDEMPOTENCY_CONFLICT; replaying identical input returns its saved result. A confirmation token does not reserve stock. A stale link version returns CONFLICT; unmet Shopify prerequisites return PRECONDITION_FAILED.

Disable with enabled: false, the current version and a new key. Disabling needs no Shopify read and leaves the last published quantity in Shopify. Retry queues fresh work for an enabled variant; publishing runs asynchronously through the existing worker/cron. Minimum edits also enqueue recalculation.

Availability publication retries retain the existing read/write scope requirements. Retry no longer rejects a stock mismatch: InvElf publishes absolute availability. Uncertain writes keep their original key and block newer revisions. Expired or disabled-target uncertainty returns a precondition error requiring operational resolution. Inbox/recovery administration is available only through admin tRPC, not through availability API tokens. See the processing contract.

Component models (v1 and v2)

The UI calls these Component models. Existing API names (ComponentTemplate, componentTemplate, componentTemplates, templateId/templateName) and the COMPONENT_TEMPLATE image target remain unchanged for compatibility.

Component models group concrete components within the token organization. They expose name, description, independent image, version and componentCount. They cannot hold stock or be recipe targets. Use componentTemplates(first,after,search) and componentTemplate(id) for reads; components(templateId,search,first,after) lists their concrete members. Passing null templateId selects unassigned components; omission leaves the collection unfiltered. Component records expose templateId/templateName. Filtered cursors are bound to the filter.

createComponentTemplate(input,idempotencyKey) accepts required name, optional description and up to 100 components[{id,expectedVersion}]. updateComponentTemplate requires the expected component model version. setComponentTemplate(input:{templateId,components,idempotencyKey}) atomically assigns/moves selected components; null clears. Component create/update inputs also accept optional templateId; omitted updates preserve it and null clears it. Concurrent component changes return VERSION_CONFLICT without partially applying a batch.

deleteComponentTemplate(id,expectedVersion,confirm:true,idempotencyKey) retains components and clears connections. It requires catalog:delete and, if assigned, catalog:write. No inventory scope is needed. Empty component models only require catalog:delete. Members with catalog:read can browse; every write still requires the token owner's admin membership.

Use the image endpoint with targetType=COMPONENT_TEMPLATE and the current component model version to upload/replace. updateComponentTemplate(input:{name,clearImage:true},...) clears explicitly; otherwise image identity is preserved. Component images never inherit the component model image. All usual API bounds and idempotency rules apply; see generated SDL for complete typed inputs and results.