Products and Inventory

Account access and beta API update — deployment and validation pending

As of October 23, 2026, the account access, partial editing, search and catalog changes described here are implemented locally and await deployment and validation. Examples describe the updated behavior once that rollout is confirmed.

The live API reference continues to show the published OpenAPI contract. This guide does not announce new endpoints as available.

One account per API key

Send X-Api-Key with the required permission scope. For the operations below, the updated API takes the account from the validated key. A different account in a header, query parameter, or request body does not switch that account.

If your integration manages three independent Wahio accounts, use a key issued by each account and keep their product and warehouse identifiers separate. Do not send x-account-id to select another account.

Product and inventory operations included in the pending rollout
OperationPermissionUpdated account behavior
POST /v1/productsproducts:writeCreate a product with an existing category from the account associated with your key.
PATCH /v1/products/{id}products:writeUpdate only supported attributes; Products loads the stored aggregate and publishes its usual update event.
GET /v1/products/{id}products:readRetrieve a product from the account associated with your key.
GET /v1/products/lookupproducts:readFind a product by exact code or SKU, including variant codes, directly in Products.
GET /v1/categoriesproducts:readList category IDs and names for product creation and partial editing.
GET /v1/warehousesproducts:readList the account’s warehouses and obtain IDs for inventory updates.
GET /v1/taxesproducts:readList the account’s configured tax IDs, values and types.
POST /v1/products/searchproducts:readSearch this account’s product index, optionally filtered by warehouse.
POST /v1/products/stockproducts:writeUpdate stock after checking the batch warehouse and products belong to that account.

Retrieve a product

Replace the placeholders with a product ID and the key for the account that owns it. No account field is required in this request.

curl --request GET 'https://api.dev.wahio.com/api/v1/products/PRODUCT_ID' \
  --header 'Accept: application/json' \
  --header 'X-Api-Key: YOUR_ACCOUNT_API_KEY'

A missing product or one outside the key’s account returns 404. A service or network failure is an error and is not interpreted as a missing product.

Find product by barcode

Use GET /v1/products/lookup?barcode=001234 with products:read. This reads Products directly and does not wait for the search index. Send one barcode as text to preserve leading zeros, and URL-encode special characters. The account comes from your API key.

curl --get 'https://api.dev.wahio.com/api/v1/products/lookup' \
  --data-urlencode 'barcode=001234' \
  --header 'X-Api-Key: YOUR_ACCOUNT_API_KEY'

Store the barcode in code. The receiver also matches sku; reference is not searched. It compares equality after removing whitespace and null characters, stripping surrounding square/curly brackets and converting the input to uppercase. Database collation controls equality. This is neither substring search nor GTIN validation.

Variant codes and SKUs are searched when the product includes variants. A match returns the product resource with all its variants; it does not identify which variant matched. Inactive products may be returned. If multiple records match, the existing receiver returns its first match without an ambiguity guarantee. There is no warehouse filter.

Missing, blank, repeated or invalid barcode returns 400. No match in your account returns 404. Missing authentication returns 401, insufficient permission 403, and service failures 500. A failure does not mean the product is absent: reconcile before creating or moving stock again. Lookup does not deduplicate writes or retry them automatically.

List categories

Before creating a product, call GET /v1/categories with products:read. The API returns your account’s existing categories, ordered by name. Use the selected id as custom_category_id in product creation or partial editing. Keep IDs separate for each account.

curl --request GET 'https://api.dev.wahio.com/api/v1/categories' \
  --header 'X-Api-Key: YOUR_ACCOUNT_API_KEY'
[
  {
    "id": "CATEGORY_ID",
    "name": "Clothing"
  }
]

There are no filters, pagination, body or account selector. No categories returns []; configure one in Wahio before creating a product if the list is empty. This endpoint lists categories and does not create them. Missing authentication returns 401, insufficient permission 403, and service or invalid response errors 500. A service error does not mean the catalog is empty. This endpoint is pending deployment and validation in dev.

Create a product

Send POST /v1/products with name and custom_category_id. Obtain the category from GET /v1/categories using the same account key. Prices default to zero when omitted. The API returns 201 Created with the product and its generated identifier. Do not send account_id or a product ID; the account comes from your key.

{
  "name": "Coffee",
  "custom_category_id": "category-id",
  "sku": "COF-001",
  "code": "7701234567890",
  "price": 5000,
  "control_inventory": true,
  "tax_ids": [
    "TAX_ID"
  ]
}

Missing name or category returns 400; an unavailable category returns 404. Missing authenticated account returns 401. This increment connects creation and account validation. Optional tax_ids assigns existing taxes from this account; variants now accepts explicit size/color combinations. Register inventory with the stock operation after creating the product.

Creation is not idempotent. A timeout or error can occur after saving. Reconcile before retrying; duplicate checks may reject a repeated request instead of returning the original result. Search can take a short interval to show a newly created product.

Partially update a product

PATCH /v1/products/{id} replaces the former public PUT. Requires products:write. Send only the supported attributes you want to change; the account comes from your API key and the product ID from the route. There is no PUT compatibility route.

Supported optional fields are name, description, sku, code, base_price, price, measurement, category_id, custom_category_id, control_inventory, available_online and status. Send at least one non-null supported field. Custom categories must belong to your account.

{
  "price": 15000,
  "available_online": false
}
  • Omitted or null: keep the stored value.
  • 0 and false: apply those values.
  • Empty text clears description, SKU, code or measurement. Name and category IDs cannot be empty.
  • Unknown fields are ignored. Empty, all-null or unsupported-only changes return 400.

The response is 200 with the updated product. Products loads the stored aggregate, applies the allowed fields and runs its usual validations, recalculations and update events. Search reflects the changes asynchronously. This JSON contract does not use null to delete values.

Relationships, stock, calculated costs, metrics and audit fields cannot be supplied here. Editing base_price still applies the existing cost recalculation rules; it does not register a purchase. An error can occur after saving; reconcile an uncertain result before retrying.

Deactivate a product

Send PATCH /v1/products/{id} with products:write and the following body. Use the product ID from creation or lookup; the account comes from your API key.

{
  "status": "disabled"
}

To reactivate, send {"status":"active"}. Only disabled and active are accepted, exactly lowercase. Empty text, other values and deletion states return 400. Omitted or null status keeps the stored state; null alone returns 400 because there is no change. An unknown product or one outside your account returns 404. Success returns 200 with the updated product and status.

Deactivation preserves the record and its relationships; it does not reverse stock movements or delete inventory. It does not change available_online or e-commerce visibility fields. Correct an accidental product using the supported PATCH attributes, and correct inventory separately. Code/SKU reuse for a new product is not guaranteed by deactivation.

Products uses its existing update validations and events. After indexing, Search maps disabled to Inactive, so ordinary active-product searches exclude it. Explicit filters and catalog queries follow their own rules. GET by ID and exact barcode lookup can still return inactive products. This does not guarantee immediate hiding or prevent all sales and stock operations in other consumers.

This increment requires the updated Products PATCH receiver before the public gateway is deployed. After an uncertain error, read the product by ID to reconcile its state; the existing event publication can fail after saving. There is no new automatic recovery or deletion endpoint.

Create products with size and color variants

Send variants in POST /v1/products with products:write. Each item is one explicit combination; the API does not generate a Cartesian product. Omitted, null or an empty array creates a product without variants.

{
  "name": "Shirt",
  "custom_category_id": "CATEGORY_ID",
  "variants": [
    {
      "code": "77001",
      "sku": "BLUE-M",
      "base_price": 75,
      "price": 100,
      "price_2": 95,
      "price_10": 80,
      "options": [
        {
          "name": "Color",
          "value": "Blue"
        },
        {
          "name": "Size",
          "value": "M"
        }
      ]
    },
    {
      "code": "77002",
      "sku": "BLUE-L",
      "base_price": 75,
      "price": 100,
      "price_2": 95,
      "price_10": 80,
      "options": [
        {
          "name": "Color",
          "value": "Blue"
        },
        {
          "name": "Size",
          "value": "L"
        }
      ]
    }
  ]
}

Each variant accepts only code, sku, base_price, price, price_2 through price_10, and options. Prices default to zero and must be non-negative; they do not inherit the product’s prices. Code and SKU are optional, with up to 100 characters each. Options must contain non-empty names and values, with no repeated name in one variant. All variants must use the same option names in the same order (case-sensitive). Invalid variants return 400. Service products cannot have variants.

Products generates variant IDs and links them to the new product in the account associated with the key. The response includes variantswith generated id, commercial fields and options. includes_variants is derived from the list; the flag alone does not create variants.is_parent refers to composite products, not the parent of size/color variants. Tax IDs apply to the product, not individual variants.

Do not send IDs, parent links, stock or calculated costs inside variants. Existing Products validations, saving and events remain in use; search reflects changes asynchronously. Stock updates accept product_variant_id; editing variants remains pending. As with simple creation, reconcile uncertain results before retrying. Deployment and remote validation remain pending.

Assign taxes when creating a product

First call GET /v1/taxes using the same account’s key. Send the chosen IDs in tax_ids in POST /v1/products, which requires products:write. Choose the existing tax configured for your product; this operation does not create or modify a tax definition.

{
  "name": "Coffee",
  "custom_category_id": "CATEGORY_ID",
  "price": 119,
  "tax_ids": [
    "TAX_ID"
  ]
}

Omit tax_ids, send null, or send [] to create without tax associations. Entries must be non-empty IDs; invalid input returns 400. An unknown tax or one from another account returns 404 before creation. Repeated IDs are consolidated by Products. For multiple taxes, send one ID per applicable tax, rather than alternative rates for the same tax.

Do not send has_taxes; the response flag is calculated from the associations. Nested tax objects, rates and relationship IDs are not accepted as inputs. Products validates ownership and uses its existing save and event flow. The response remains the Product resource with its has_taxes flag. No tax IDs means no associations, which does not by itself classify a product as fiscally excluded. Tax editing through PATCH remains outside this update.

Warehouse and tax catalogs

Use GET /v1/warehouses and GET /v1/taxes with your account’s key and products:read. Neither endpoint accepts an account selector or pagination. Each returns a JSON array; an account with no entries returns []. For three accounts, call both catalogs with each account’s own key and store identifiers separately.

curl 'https://api.dev.wahio.com/api/v1/warehouses' \
  --header 'X-Api-Key: YOUR_ACCOUNT_API_KEY'

curl 'https://api.dev.wahio.com/api/v1/taxes' \
  --header 'X-Api-Key: YOUR_ACCOUNT_API_KEY'

Warehouse response example

[
  {"id":"WAREHOUSE_ID","name":"Main warehouse","address":"Example street","allow_negative_stock":false}
]

Send the returned id as warehouse_id in stock updates. address may be null;allow_negative_stock reports the warehouse setting. This catalog does not include stock totals or valuations.

Tax response example

[
  {"id":"TAX_ID","name":"IVA 19%","description":"Standard VAT","tribute_type":"01","value":19,"type":"percentage","is_include":true}
]

Fields reflect the account’s stored configuration. type is percentage or amount; value: 19 with type: "percentage" means 19%. tribute_type is the configured tribute classification, and is_include reports whether the tax is included. A zero value alone does not identify an excluded product. To assign a listed tax when creating a product, send its ID in tax_ids as shown in the creation example.

Missing or invalid authentication returns 401; insufficient permission returns 403. An upstream error returns 500, rather than an empty catalog. These endpoints await deployment and remote validation.

Search products

POST /v1/products/search searches the product index with text, inventory, prices, variants and membership filters. Requires products:read. All request fields are optional: send {} to list active products. The account always comes from your API key; do not send accountId.

Product search parameters and defaults
ParameterUsage
querySearch text, or "*" for all products (default). Searches names, SKU, descriptions, indexed content and locations.
warehouseIdOptional warehouse. Omit to search all warehouses belonging to your account.
productCategoryName / measurementExact category name or measurement value.
productId / productStatusRestrict by product ID or status. Use Active or Inactive (case-sensitive); normal searches default to Active. With productId alone, the active-only default is not applied.
salesCatalogId / collectionIdRestrict membership to an active sales catalog, a collection, or both. Catalogs are independent of warehouses.
filterAdditional OData conditions. Use the PascalCase index field names shown below.
orderByField and direction, such as "ProductName asc", "Available desc", "Price1 asc" or "UpdatedAt desc". Omit for relevance.
facetsValue counts for requested fields. Counts help discover available categories and attributes; they do not apply filters automatically.
pageNumber / pageSizePage starts at 1. Defaults: pageNumber 1, pageSize 20. Maximum pageSize: 100.

Filter recipes

Request fields use camelCase, but names inside filter, orderBy and facets use the case-sensitive PascalCase index names. Combine conditions with and, or, not and parentheses. Numeric comparisons support eq, ne, gt, ge, lt and le. Quote strings with single quotes; escape an apostrophe by doubling it, as in 'Men''s clothing'. Use the dedicated parameters for warehouse, product and status restrictions.

Example OData product filters
FindFilter expression
Available inventoryAvailable gt 0
Out of stockAvailable le 0
Physical stockStock ge 10
Price list rangePrice1 ge 5000 and Price1 le 50000
Base price rangePriceBase ge 5000 and PriceBase le 50000
Category IDProductCategoryId eq 'category-id'
With variantsHasVariants eq true
Without imagesHasImage eq false
Raw materialsIsRawMaterial eq true
Serials and optionalsIncludesSerials eq true and IncludesOptionals eq false
Variant attributeVariantAttributeValues/any(v: v eq 'Color:Rojo')
FeatureFeatureKeys/any(f: f eq 'Material:Algodón')
Custom fieldCustomFieldKeys/any(f: f eq 'proveedor:sony')
With discountsDiscountKeys/any()
Changed sinceUpdatedAt ge 2026-10-01T00:00:00Z

Variant attribute filters select products, not individual variants to return. Catalog and collection membership can limit returned variants, while filters and facet counts use the indexed document’s full variant aggregates. Text search is not an exact barcode lookup.

Search available products with a price range

Send this JSON body with your account’s API key. Replace the warehouse identifier with your own.

{
  "query": "shirt",
  "warehouseId": "warehouse-id",
  "filter": "Available gt 0 and Price1 ge 5000 and Price1 le 50000",
  "orderBy": "ProductName asc",
  "facets": [
    "ProductCategoryName,count:50",
    "VariantAttributeValues"
  ],
  "pageNumber": 1,
  "pageSize": 20
}

Discover filters with facets

Useful facets include ProductCategoryName, WarehouseId, Measurement, VariantAttributeNames, VariantAttributeValues, FeatureKeys, CustomFieldKeys and DiscountKeys. Append count:50 to request up to 50 values. After selecting a category from the counts, search again with ProductCategoryName eq 'Clothing' in filter.

Search a catalog and collection

{
  "salesCatalogId": "catalog-id",
  "collectionId": "collection-id",
  "warehouseId": "warehouse-id",
  "orderBy": "ProductName asc"
}

With a catalog or collection, only active products are supported. An unavailable membership returns an empty page. Allowed facets are ProductCategoryName, WarehouseId, Measurement, VariantAttributeValues, FeatureKeys, CustomFieldKeys and References. Sort by ProductName, PriceBase, Price1 through Price10, TotalSales, Available, Stockor Weight, followed by asc or desc.

Read the response

This shortened example shows pagination, prices and category counts. Each result represents a product in a warehouse. Without warehouseId, one product may appear in multiple warehouses. Use productId for product operations; id identifies its search document. For variants, use productVariantId; variant prices and attributes are under variants. Facet keys retain their PascalCase names.

{
  "totalCount": 1,
  "pageNumber": 1,
  "pageSize": 20,
  "totalPages": 1,
  "results": [
    {
      "id": "warehouse-id_product-id",
      "productId": "product-id",
      "warehouseId": "warehouse-id",
      "productName": "Shirt",
      "productSku": "SH-001",
      "stock": 4,
      "reserved": 1,
      "available": 3,
      "prices": {
        "base": 10000,
        "price1": 15000
      },
      "hasVariants": false,
      "variants": []
    }
  ],
  "facets": {
    "ProductCategoryName": [
      {
        "value": "Clothing",
        "count": 1
      }
    ]
  }
}

Synchronization and beta contract

New products and inventory changes synchronize asynchronously and may take a short interval to appear. There is no guaranteed one-second maximum. An empty result does not prove a product was not created; retrieve it by ID to check a recent write. Full index rebuilds can temporarily return partial or empty results.

This beta contract replaces the former snake_case search fields and summary response without a compatibility adapter. It is pending deployment and validation; the live API reference reflects the currently published backend.

Update stock

Stock updates continue to use updates, with warehouse_id, product_id, action, origin, and a positive quantity. Use one warehouse per batch. The account checks run before processing movements; an unavailable warehouse or product rejects the batch.

To update a variant, also send product_variant_id from variants[].id in the product response. Keep product_id as the owning product’s ID. Omit the variant ID or send null for the existing product-level operation; empty or whitespace IDs return 400. The response includes product_variant_id, stock and reserved for each returned record.

{
  "updates": [
    {
      "warehouse_id": "WAREHOUSE_ID",
      "product_id": "PRODUCT_ID",
      "product_variant_id": "VARIANT_ID",
      "action": "add",
      "origin": "adjustment",
      "quantity": 2
    }
  ]
}

Use action: remove with a positive quantity to subtract stock. Product, variant and warehouse IDs must refer to the intended combination in the account associated with the key. Errors are returned without retrying as a product-only update. An unavailable combination may produce a service error; do not assume every invalid variant is reported as 404. The existing stock engine determines parent totals and other effects.

The reference labels this operation Update stock (UpdateStock). Its new HTTP route is POST /v1/products/stock; the warehouse is still required in each update.

Purchase costs

For origin: purchase, send both unit_cost (cost per unit with taxes) and unit_cost_base (cost per unit without taxes). They are unit amounts, not the total for the line. The API does not infer tax rates or convert one value into the other. Both must be finite and non-negative; zero is valid. Omitting either value or sending null returns 400 before the request reaches Products.

{
  "updates": [
    {
      "warehouse_id": "WAREHOUSE_ID",
      "product_id": "PRODUCT_ID",
      "product_variant_id": "VARIANT_ID",
      "action": "add",
      "origin": "purchase",
      "quantity": 5,
      "unit_cost": 119,
      "unit_cost_base": 100,
      "reference": "SUPPLIER-INVOICE-123"
    }
  ]
}

Products stores the last purchase costs and recalculates the weighted averages on the actual product or variant. For example, 10 units at an average cost of 100, followed by 5 units at 130, gives a new average of (10 × 100 + 5 × 130) / 15 = 110. The same calculation runs independently without taxes. It uses stock across warehouses when available and advances that quantity between rows in one batch. If prior stock is non-positive or both previous averages are zero, the existing engine initializes the averages from the incoming costs. When the final balance is non-positive it does not recalculate the averages.

A purchase also sets the product or variant’s base price from unit_cost, following the existing Orders purchase mapping. Send action: removeonly if you intend the existing purchase-removal behavior: it subtracts quantity and cost value and also replaces the last purchase costs. A normal sale removal does not replace last purchase or weighted-average costs. reference is recorded as a document reference; it does not deduplicate the operation.

For other origins the cost pair is optional, but if you supply one field you must supply both. Other positive entries can change weighted averages without updating last purchase costs. If their costs are omitted, the existing engine uses zero; omission does not preserve the previous valuation. Supply the pair for a valued initial stock or adjustment. This update does not recalculate simple average costs, purchase dates or counters.

The stock response continues to return quantities and identifiers, not calculated costs. GET product exposes the product’s existing cost fields; variant cost readback is outside this update. Implementation is local and requires the corrected Products receiver before remote validation. Stock changes remain non-idempotent: reconcile an uncertain result before retrying. Simultaneous requests are not covered by a new concurrency guarantee.

Scope of this update

Variant editing remains pending. Creation accepts tax_ids; editing tax associations through PATCH remains pending.

The approved quota is 60 admitted requests per account/environment in a rolling 60 seconds, shared by all its API keys and operations. Implementation awaits deployment. Read the usage limits and Retry-After rules and the testing and sandbox restrictions before testing writes. The playground sends real requests; a pk_test_ key does not simulate product or stock changes.

Open the published API reference