> ## Documentation Index
> Fetch the complete documentation index at: https://docs.filexpress.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Inventory

> Stock, movements, batches, alerts, adjustments and transfers between branches

| Permission | Endpoints |
| - | - |
| `inventory.read` | `GET /inventory/stock`, `/movements`, `/batches`, `/alerts`, `/transfers`, `/transfers/{id}` |
| `inventory.adjustments` | `POST /inventory/adjustments` |
| `inventory.transfers` | `POST /inventory/transfers`, `/transfers/{id}/complete`, `/transfers/{id}/cancel` |

> [!NOTE]
>
> These are **explicit** permissions: an integration only has them if the administrator checks them one by one in **External integrations**. Existing integrations do not receive them automatically.

* Only **physical** products track inventory; services do not appear in stock and cannot be adjusted or transferred.
* Everything is limited to the integration's business: a branch, product or transfer from another business returns `404`.
* `unit_cost` is always the product's **cost** (not the sale price). Adjustments and transfers are ready to be posted at cost from [Process documents](https://docs.filexpress.app/en/docs/contabilidad/procesar-documentos.md#inventory-adjustments-and-transfers).
* Writes accept the [`Idempotency-Key`](https://docs.filexpress.app/en/docs/api/introduccion#idempotency) header. Always send it so you don't duplicate an adjustment or a transfer when retrying.

## Query stock

```http theme={null}
GET /inventory/stock?branch_id={branch_id}&low_stock=true
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `branch_id` | string | Yes | Branch. |
| `search` | string | No | Searches name, SKU or barcode (max. 100 characters). |
| `low_stock` | boolean | No | `true`: only products with low stock (stock less than or equal to the minimum, when the minimum is greater than 0). |
| `per_page` | integer | No | 1 to 200 (default 50). |

```bash theme={null}
curl "https://api.filexpress.app/api/external/v1/inventory/stock?branch_id=9d1c5a3e-...&low_stock=true" \
  -H "X-API-Key: fx_xxxxxxxx" -H "Accept: application/json"
```

```json theme={null}
{
  "status": true,
  "data": {
    "data": [
      {
        "product_id": "9d2a...",
        "branch_id": "9d1c5a3e-...",
        "sku": "CAF-500",
        "barcode": "7401234567890",
        "name": "Café molido 500 g",
        "availability": 4,
        "min_stock": 10,
        "is_low_stock": true,
        "unit_cost": 3.25
      }
    ],
    "pagination": { "current_page": 1, "last_page": 1, "per_page": 50, "total": 1 }
  }
}
```

| Field | Description |
| - | - |
| `availability` | Current stock. |
| `min_stock` | Minimum configured on the product (`0` = no minimum). |
| `is_low_stock` | `true` if `min_stock > 0` and `availability <= min_stock`. |
| `unit_cost` | Product cost. |

## Query movements

```http theme={null}
GET /inventory/movements?branch_id={branch_id}&start_date=2026-10-01&end_date=2026-10-31
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `branch_id` | string | Yes | Branch. |
| `start_date`, `end_date` | date | Yes | `YYYY-MM-DD` range (maximum 93 days). |
| `product_id` | string | No | Filters by product. |
| `source` | string | No | `adjustment` (adjustments) or `transfer` (transfers). |
| `per_page` | integer | No | 1 to 500 (default 100). |

```json theme={null}
{
  "status": true,
  "data": {
    "data": [
      {
        "id": "9d3b...",
        "branch_id": "9d1c5a3e-...",
        "product": { "id": "9d2a...", "sku": "CAF-500", "name": "Café molido 500 g" },
        "type": "out",
        "quantity": 2,
        "unit_cost": 3.25,
        "source": "adjustment",
        "reason": "Merma por empaque dañado",
        "notes": null,
        "branch_transfer_id": null,
        "created_at": "2026-10-06T10:15:00-06:00"
      }
    ],
    "pagination": { "current_page": 1, "last_page": 1, "per_page": 100, "total": 1 }
  }
}
```

* `type`: `in` (inbound) or `out` (outbound). `quantity` is always positive.
* `source`: `adjustment`, `transfer`, `sale`, `purchase` or `null` (older movements with no recorded source).
* `unit_cost` can be `null` on movements prior to this version.

## Query batches

```http theme={null}
GET /inventory/batches?branch_id={branch_id}&status=expiring&days=15
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `branch_id` | string | Yes | Branch. |
| `product_id` | string | No | Filters by product. |
| `status` | string | No | `available` (in stock), `expiring` (about to expire) or `expired` (expired). |
| `days` | integer | No | Window for `expiring`, 1 to 365 (default 30). |
| `per_page` | integer | No | 1 to 200 (default 50). |

Batches are sorted by expiration date. Each item:

```json theme={null}
{
  "id": "9d4c...",
  "branch_id": "9d1c5a3e-...",
  "product": { "id": "9d2a...", "sku": "AMX-500", "name": "Amoxicilina 500 mg" },
  "batch_number": "L2026-031",
  "expiration_date": "2026-10-20",
  "days_to_expire": 14,
  "quantity": 100,
  "available_quantity": 37,
  "is_expired": false,
  "is_removed": false
}
```

`days_to_expire` is negative if the batch has already expired.

## Alerts

```http theme={null}
GET /inventory/alerts?branch_id={branch_id}&days=30
```

Gathers low stock, batches about to expire and expired batches with stock in a single call (up to 500 of each type).

| Parameter | Type | Required | Description |
| - | - | - | - |
| `branch_id` | string | Yes | Branch. |
| `days` | integer | No | Window for batches about to expire, 1 to 365 (default **30**). |

```json theme={null}
{
  "status": true,
  "data": {
    "days": 30,
    "low_stock": [ { "product_id": "9d2a...", "sku": "CAF-500", "availability": 4, "min_stock": 10, "is_low_stock": true, "...": "..." } ],
    "expiring_batches": [ { "batch_number": "L2026-031", "days_to_expire": 14, "...": "..." } ],
    "expired_batches": []
  }
}
```

`low_stock` uses the [stock](#query-stock) format and the batches use the [batches](#query-batches) format.

## Register an adjustment

```http theme={null}
POST /inventory/adjustments
```

Registers a **shortage/shrinkage** (`out`) or a **surplus** (`in`) and updates stock immediately. The movement stores the product's unit cost at that moment.

| Field | Type | Required | Description |
| - | - | - | - |
| `branch_id` | string | Yes | Branch. |
| `product_id` | string | Yes | Product in that branch. |
| `type` | string | Yes | `in` (surplus) or `out` (shortage/shrinkage). |
| `quantity` | number | Yes | Greater than 0. |
| `reason` | string | Yes | Reason (max. 255 characters). |
| `notes` | string | No | Notes (max. 500 characters). |

```bash theme={null}
curl -X POST https://api.filexpress.app/api/external/v1/inventory/adjustments \
  -H "X-API-Key: fx_xxxxxxxx" \
  -H "Idempotency-Key: conteo-2026-10-06-CAF-500" \
  -H "Content-Type: application/json" \
  -d '{
    "branch_id": "9d1c5a3e-...",
    "product_id": "9d2a...",
    "type": "out",
    "quantity": 2,
    "reason": "Merma por empaque dañado"
  }'
```

**`201` response**: the movement and the updated stock.

```json theme={null}
{
  "status": true,
  "data": {
    "adjustment": {
      "id": "9d3b...",
      "branch_id": "9d1c5a3e-...",
      "product": { "id": "9d2a...", "sku": "CAF-500", "name": "Café molido 500 g" },
      "type": "out",
      "quantity": 2,
      "unit_cost": 3.25,
      "source": "adjustment",
      "reason": "Merma por empaque dañado",
      "notes": null,
      "branch_transfer_id": null,
      "created_at": "2026-10-06T10:15:00-06:00"
    },
    "stock": {
      "product_id": "9d2a...",
      "branch_id": "9d1c5a3e-...",
      "sku": "CAF-500",
      "barcode": "7401234567890",
      "name": "Café molido 500 g",
      "availability": 2,
      "min_stock": 10,
      "is_low_stock": true,
      "unit_cost": 3.25
    }
  }
}
```

> [!NOTE]
>
> The adjustment does not validate available stock: a shortage greater than the stock leaves the product negative. Check [stock](#query-stock) first if your process requires it.

**Errors**

| Code | Cause |
| - | - |
| `404` | `Branch not found` / `Product not found in this branch`. |
| `422` | Validation, or `Solo los productos físicos manejan inventario.` |
| `409` | `Idempotency-Key` conflict. |

## Transfers between branches

A transfer has three statuses:

| Status | Meaning |
| - | - |
| `pending` | Created; stock has not moved yet. |
| `completed` | Stock left the origin and entered the destination. |
| `cancelled` | Voided without moving stock. Only `pending` transfers can be cancelled. |

### Create a transfer

```http theme={null}
POST /inventory/transfers
```

Creates the transfer in `pending` status. With `"complete": true` it also completes it in the same call.

| Field | Type | Required | Description |
| - | - | - | - |
| `origin_branch_id` | string | Yes | Sending branch. |
| `destination_branch_id` | string | Yes | Receiving branch (different from the origin and in the same business). |
| `products` | array | Yes | 1 to 500 items. |
| `products[].product_id` | string | Yes | Product in the **origin** branch. |
| `products[].quantity` | number | Yes | Greater than 0 and not greater than the stock at the origin. |
| `reference_number` | string | No | Reference (max. 100). If omitted, `TRANS-<timestamp>` is generated. |
| `notes` | string | No | Notes (max. 1000). |
| `complete` | boolean | No | `true` to complete it immediately. |

```bash theme={null}
curl -X POST https://api.filexpress.app/api/external/v1/inventory/transfers \
  -H "X-API-Key: fx_xxxxxxxx" \
  -H "Idempotency-Key: traslado-000457" \
  -H "Content-Type: application/json" \
  -d '{
    "origin_branch_id": "9d1c5a3e-...",
    "destination_branch_id": "9d1c5a40-...",
    "reference_number": "TR-000457",
    "products": [ { "product_id": "9d2a...", "quantity": 12 } ],
    "complete": true
  }'
```

**`201` response**

```json theme={null}
{
  "status": true,
  "data": {
    "id": "9d5d...",
    "reference_number": "TR-000457",
    "status": "completed",
    "origin_branch": { "id": "9d1c5a3e-...", "name": "Casa Matriz" },
    "destination_branch": { "id": "9d1c5a40-...", "name": "Sucursal Santa Ana" },
    "notes": null,
    "items": [
      { "product_id": "9d2a...", "sku": "CAF-500", "name": "Café molido 500 g", "quantity": 12, "unit_cost": 3.25 }
    ],
    "total_cost": 39.0,
    "created_at": "2026-10-06T11:00:00-06:00",
    "transferred_at": "2026-10-06T11:00:00-06:00"
  }
}
```

`items[].unit_cost` is the product's cost when the transfer was created, and `total_cost` is the sum of quantity × cost.

> [!WARNING]
>
> With `complete: true`, creating and completing is a single operation: if the transfer cannot be completed (for example, the stock changed), the response is an error and **no transfer is left created**. You can fix it and retry the same call.

### Complete a transfer

```http theme={null}
POST /inventory/transfers/{id}/complete
```

```json theme={null}
{ "cost_center_id": "c3a1..." }
```

| Field | Required | Description |
| - | - | - |
| `cost_center_id` | No | [Cost center](https://docs.filexpress.app/en/docs/api/contabilidad/centros-de-costo.md) of the transfer (must be an active leaf center). It is saved for traceability and cost reports. |

The body is optional. It moves stock atomically: it deducts at the origin (only if there is still enough stock) and adds at the destination. It returns `200` with the transfer in `completed` status.

A nonexistent or inactive cost center, or one with subcenters, returns `422` and the transfer is not completed. Since inventory accounts are balance sheet accounts, the center normally does not reach the journal entry; it only does if the company configured those accounts to accept a cost center.

**Product in the destination branch**: it is looked up by **SKU**; if it does not exist, by **barcode**; if it does not exist either, a copy of the product is created at the destination (same SKU, name, category, unit, price and taxes) with stock 0 before adding the inbound quantity.

### Cancel a transfer

```http theme={null}
POST /inventory/transfers/{id}/cancel
```

No body. Only for `pending` transfers; it does not move stock. It returns `200` with the transfer in `cancelled` status.

**Transfer errors**

| Code | Cause |
| - | - |
| `400` | `Las sucursales deben pertenecer a la misma empresa` (origin or destination belongs to another business or does not exist). |
| `400` | `La sucursal destino debe ser diferente a la de origen`. |
| `400` | `Solo se pueden transferir productos físicos: <producto>`. |
| `400` | `Cantidad insuficiente para <producto>. Disponible: <n>`. |
| `400` | `Esta transferencia ya fue completada` / `No se puede completar una transferencia cancelada` / `No se puede cancelar una transferencia completada` / `Esta transferencia ya está cancelada`. |
| `404` | `Transfer not found` (nonexistent or from another business) / `Producto no encontrado en la sucursal origen`. |
| `409` | `Cantidad insuficiente para <producto>. El stock cambió por otra operación concurrente, intenta de nuevo.` No part of the transfer is applied. |
| `409` | `Idempotency-Key` conflict. |
| `422` | Validation (`errors` indicates the field). |

### Query transfers

```http theme={null}
GET /inventory/transfers?status=pending&branch_id={branch_id}
GET /inventory/transfers/{id}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `status` | string | No | `pending`, `completed` or `cancelled`. |
| `branch_id` | string | No | Transfers where the branch is the origin **or** the destination. |
| `start_date`, `end_date` | date | No | Creation range `YYYY-MM-DD`. |
| `per_page` | integer | No | 1 to 200 (default 50). |

The list is paginated (`data` + `pagination`) with the same format as the [creation response](#create-a-transfer). The detail returns a single object or `404` if it does not exist or belongs to another business.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.