# Squeeze API - specification of new endpoints for master data tables

## Background and motivation

Squeeze is used via the Business Central (BC) integration to maintain master data per company in Squeeze tables. A single Squeeze system can serve several hundred BC companies, where each company has its own record in a master data table – typically identified by a column such as `companyName` or `companyId`.

### Current problem

The existing batch endpoints under `/masterData/tables/{tableId}/rows/batch` do not sufficiently cover the use case:

<table id="bkmrk-endpoint-behavior-pr" style="width:100%;"><thead><tr><th style="width:20.5006%;">Endpoint</th><th style="width:24.7799%;">Behavior</th><th style="width:54.7195%;">Problem</th></tr></thead><tbody><tr><td style="width:20.5006%;">`PUT /rows/batch`</td><td style="width:24.7799%;">Deletes **all** existing rows and replaces them completely</td><td style="width:54.7195%;">Not usable, because the data of all companies would be deleted. To update only one company, the data of all other companies would have to be sent as well.</td></tr><tr><td style="width:20.5006%;">`POST /rows/batch`</td><td style="width:24.7799%;">Adds new rows but does **not** update existing ones</td><td style="width:54.7195%;">Creates duplicates on repeated calls instead of updating existing records.</td></tr><tr><td style="width:20.5006%;">`PUT /rows/{rowId}`</td><td style="width:24.7799%;">Updates a single row</td><td style="width:54.7195%;">For thousands of records per company this is far too slow and generates thousands of individual API requests.</td></tr><tr><td style="width:20.5006%;">`DELETE /rows/{rowId}`</td><td style="width:24.7799%;">Deletes a single row</td><td style="width:54.7195%;">Also one request per row – unsuitable for bulk operations.</td></tr></tbody></table>

### Required solution

Three new batch endpoints are needed:

1. **Batch upsert** (`PATCH /masterData/tables/{tableId}/rows/batch`) – insert or update records without deleting rows that are not affected.
2. **Batch delete by ID** (`POST /masterData/tables/{tableId}/rows/batch/delete`) – delete multiple rows by an ID list in a single request.
3. **Batch delete by filter** (`POST /masterData/tables/{tableId}/rows/batch/delete/filter`) – delete multiple rows by column filters in a single request.

---

## Endpoint 1: Batch upsert

### Overview

```
PATCH /masterData/tables/{tableId}/rows/batch
```

**Goal:** Insert or update multiple rows in a table in a single request (upsert), without affecting other rows.

**Semantics:** For each submitted row the following applies:

- If a row with the given identifier already exists → **update** the existing row.
- If no row with the given identifier exists yet → **insert** as a new row.
- All other rows in the table that are not included in the request remain **unchanged**.

---

### HTTP method

`PATCH` – because the request represents a partial change to the table content (not a complete replacement as with `PUT`).

---

### URL parameters

<table id="bkmrk-parameter-in-type-re"><thead><tr><th>Parameter</th><th>In</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td>`tableId`</td><td>path</td><td>integer</td><td>yes</td><td>ID of the master data table</td></tr></tbody></table>

### Query parameters

<table id="bkmrk-parameter-type-requi" style="width:100%;"><thead><tr><th style="width:16.4482%;">Parameter</th><th style="width:8.69587%;">Type</th><th style="width:5.72606%;">Required</th><th style="width:11.7998%;">Default</th><th style="width:57.3302%;">Description</th></tr></thead><tbody><tr><td style="width:16.4482%;">`useExternalId`</td><td style="width:8.69587%;">boolean</td><td style="width:5.72606%;">no</td><td style="width:11.7998%;">`false`</td><td style="width:57.3302%;">Analogous to the existing single-row endpoints. If `true`, the `externalId` of each row is used as the key for the upsert match. If `false` (default), the internal row ID (auto-increment) is used as the key.</td></tr><tr><td style="width:16.4482%;">`skipErrors`</td><td style="width:8.69587%;">boolean</td><td style="width:5.72606%;">no</td><td style="width:11.7998%;">`false`</td><td style="width:57.3302%;">If `true`, faulty rows are skipped and the remaining rows are processed. If `false`, the entire batch is rolled back transactionally on an error (see the "Behavior on errors" section).</td></tr></tbody></table>

<p class="callout info">**Note on the key modes:**</p>

- **`useExternalId=false` (default):** Each row must contain the internal `id` (integer, auto-increment). The API checks by this ID whether the row exists. This mode is suitable when the internal Squeeze IDs are known.
- **`useExternalId=true`:** Each row must contain the `externalId` field (string). The API checks by this value whether a row exists. In the context of Business Central, the `externalId` is typically composed of a combination of **SystemId**, **table name**, and **company name**, making it globally unique across all companies.

If the respective key field (`id` or `externalId`) is missing in a row, that row is treated as an error.

---

### Request body

`Content-Type: application/json`

A JSON array of objects. Each object represents a row as a key-value map (column name → value). Depending on the `useExternalId` parameter, each row must contain either the `id` field (internal) or `externalId`.

**Example with `useExternalId=true` (BC use case):**

```
[
  {
    "externalId": "CRONUS|Item|27f3a1c2-...",
    "companyCode": "CRONUS",
    "articleNo": "1000",
    "description": "Bicycle",
    "price": "599.00"
  },
  {
    "externalId": "CRONUS|Item|91b8d4f7-...",
    "companyCode": "CRONUS",
    "articleNo": "1001",
    "description": "Helmet",
    "price": "49.90"
  }
]

```

**Example with `useExternalId=false` (default, internal ID):**

```
[
  {
    "id": 101,
    "articleNo": "1000",
    "description": "Bicycle",
    "price": "599.00"
  },
  {
    "articleNo": "9999",
    "description": "New article",
    "price": "12.50"
  }
]

```

<p class="callout info">Rows without an `id` field are inserted as new records in the default mode (`useExternalId=false`). Rows with a known `id` are updated.</p>

---

### Response

#### HTTP 200 – Success

Response format identical to `MasterDataUploadResponse` (already used for `PUT /rows/batch` and `POST /rows/batch`):

```
{
  "error": false,
  "totalLines": 2,
  "errorLineAmount": 0,
  "skipErrors": false,
  "dataType": "json",
  "errorLines": []
}

```

<table id="bkmrk-field-type-descripti"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody><tr><td>`error`</td><td>boolean</td><td>`true` if at least one error occurred</td></tr><tr><td>`totalLines`</td><td>integer</td><td>Total number of processed rows</td></tr><tr><td>`errorLineAmount`</td><td>integer</td><td>Number of faulty rows</td></tr><tr><td>`skipErrors`</td><td>boolean</td><td>Indicates whether errors were skipped</td></tr><tr><td>`dataType`</td><td>string</td><td>Data format of the input (`"json"`)</td></tr><tr><td>`errorLines`</td><td>string\[\]</td><td>List of faulty rows (as a string representation)</td></tr></tbody></table>

Recommendation: It would be helpful if the response additionally distinguished between `insertedLines` and `updatedLines`, so that the caller receives feedback about the upsert effect:

```
{
  "error": false,
  "totalLines": 50,
  "insertedLines": 5,
  "updatedLines": 45,
  "errorLineAmount": 0,
  "skipErrors": false,
  "dataType": "json",
  "errorLines": []
}

```

#### HTTP 400 – Bad request

When a row is missing the required key field (with `useExternalId=false` the `id`, with `useExternalId=true` the `externalId`):

```
{
  "message": "Row at index 2 is missing the required key field 'externalId'.",
  "statusCode": 400
}

```

#### HTTP 404 – Table not found

```
{
  "message": "Master data table with id 42 does not exist.",
  "statusCode": 404
}

```

---

### Behavior on errors

- If `skipErrors=false` (default): The entire batch is processed **transactionally**. If an error occurs on a row, **all changes already made in the batch are rolled back**. The response contains a detailed error message with the index of the faulty row and the reason for the error:

```
{
  "error": true,
  "message": "Batch aborted: row at index 3 failed – column 'price' contains invalid value 'abc'. All changes have been rolled back.",
  "statusCode": 422
}

```

- If `skipErrors=true`: Faulty rows are skipped and documented in `errorLines`. Correct rows are committed. No rollback takes place.

---

### Example call (BC use case with externalId)

```
PATCH /api/v2/masterData/tables/42/rows/batch?useExternalId=true&skipErrors=false
Content-Type: application/json
Authorization: Bearer <token>

[
  { "externalId": "CRONUS|Item|27f3a1c2-...", "companyCode": "CRONUS", "articleNo": "1000", "description": "Bicycle", "price": "599.00" },
  { "externalId": "CRONUS|Item|91b8d4f7-...", "companyCode": "CRONUS", "articleNo": "9999", "description": "New article", "price": "12.50" }
]

```

**Expected result:**

- The row with `externalId = "CRONUS|Item|27f3a1c2-..."` already exists → `description` and `price` are updated.
- The row with `externalId = "CRONUS|Item|91b8d4f7-..."` does not exist yet → a new row is inserted.
- All other rows in the table remain unchanged.

---

---

## Endpoint 2: Batch delete by ID list

### Overview

```
POST /masterData/tables/{tableId}/rows/batch/delete

```

**Goal:** Delete multiple rows of a table by a list of internal IDs or external IDs in a single request.

---

### URL parameters

<table id="bkmrk-parameter-in-type-re-1"><thead><tr><th>Parameter</th><th>In</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td>`tableId`</td><td>path</td><td>integer</td><td>yes</td><td>ID of the master data table</td></tr></tbody></table>

### Query parameters

<table id="bkmrk-parameter-type-requi-1" style="width:100%;"><thead><tr><th style="width:16.0845%;">Parameter</th><th style="width:10.018%;">Type</th><th style="width:10.4887%;">Required</th><th style="width:10.2503%;">Default</th><th style="width:53.2777%;">Description</th></tr></thead><tbody><tr><td style="width:16.0845%;">`useExternalId`</td><td style="width:10.018%;">boolean</td><td style="width:10.4887%;">no</td><td style="width:10.2503%;">`false`</td><td style="width:53.2777%;">If `true`, the IDs given in the body are interpreted as external IDs (analogous to the existing single-row endpoints).</td></tr></tbody></table>

---

### Request body

`Content-Type: application/json`

```
{
  "rowIds": ["abc-123", "def-456", "ghi-789"]
}

```

<table id="bkmrk-field-type-required-" style="width:100%;"><thead><tr><td style="width:15.6138%;">Field</td><td style="width:14.7795%;">Type</td><td style="width:21.9309%;">Required</td><td style="width:47.6758%;">Description</td></tr></thead><tbody><tr><td style="width:15.6138%;">`rowIds`</td><td style="width:14.7795%;">string\[\]</td><td style="width:21.9309%;">yes</td><td style="width:47.6758%;">List of the row IDs to delete. With `useExternalId=false`, internal IDs (integer as string); with `useExternalId=true`, external IDs (for example `"CRONUS|Item|27f3a1c2-..."`).</td></tr></tbody></table>

---

### Response

#### HTTP 200 – Success

```
{
  "deletedRows": 3
}

```

<table id="bkmrk-field-type-descripti-1"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody><tr><td>`deletedRows`</td><td>integer</td><td>Number of rows actually deleted</td></tr></tbody></table>

#### HTTP 400 – Bad request

When `rowIds` is missing or is an empty array:

```
{
  "message": "Request body must contain a non-empty 'rowIds' array.",
  "statusCode": 400
}

```

#### HTTP 404 – Table not found

```
{
  "message": "Master data table with id 42 does not exist.",
  "statusCode": 404
}

```

---

### Example call: delete by internal ID

```
POST /api/v2/masterData/tables/42/rows/batch/delete
Content-Type: application/json
Authorization: Bearer <token>

{
  "rowIds": ["101", "102", "103"]
}

```

### Example call: delete by externalId (BC use case)

```
POST /api/v2/masterData/tables/42/rows/batch/delete?useExternalId=true
Content-Type: application/json
Authorization: Bearer <token>

{
  "rowIds": ["CRONUS|Item|27f3a1c2-...", "CRONUS|Item|91b8d4f7-..."]
}

```

**Expected result:** The specified rows are deleted. All other rows remain untouched.

---

---

## Endpoint 3: Batch delete by filter

### Overview

```
POST /masterData/tables/{tableId}/rows/batch/delete/filter

```

**Goal:** Delete all rows of a table that match one or more column filters – in a single request.

---

### URL parameters

<table id="bkmrk-parameter-in-type-re-2"><thead><tr><th>Parameter</th><th>In</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td>`tableId`</td><td>path</td><td>integer</td><td>yes</td><td>ID of the master data table</td></tr></tbody></table>

---

### Request body

`Content-Type: application/json`

Filter structure analogous to the existing endpoint `POST /masterData/tables/{tableId}/rows/search`:

```
{
  "filters": [
    {
      "columnName": "companyCode",
      "operand": "=",
      "searchValue": "CRONUS"
    }
  ],
  "isAndConnectedQuery": true
}

```

<table id="bkmrk-field-type-required--1" style="width:100%;"><thead><tr><th style="width:22.646%;">Field</th><th style="width:21.0943%;">Type</th><th style="width:4.65066%;">Required</th><th style="width:51.6091%;">Description</th></tr></thead><tbody><tr><td style="width:22.646%;">`filters`</td><td style="width:21.0943%;">MasterDataColumnFilter\[\]</td><td style="width:4.65066%;">yes</td><td style="width:51.6091%;">List of column filters. Must contain at least one filter.</td></tr><tr><td style="width:22.646%;">`isAndConnectedQuery`</td><td style="width:21.0943%;">boolean</td><td style="width:4.65066%;">no</td><td style="width:51.6091%;">`false` = filters are connected with OR, `true` = filters are connected with AND. Default: `false`.</td></tr></tbody></table>

**MasterDataColumnFilter schema** (already present in the API):

```
{
  "columnId": 5,
  "columnName": "companyCode",
  "operand": "=",
  "searchValue": "CRONUS",
  "lookupFieldFilter": false
}

```

<table id="bkmrk-field-type-descripti-2"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody><tr><td>`operand`</td><td>string</td><td>Comparison operator (for example `=`, `!=`, `LIKE`, `>`, `<`)</td></tr><tr><td>`columnId`</td><td>integer</td><td>ID of the column (alternative to `columnName`)</td></tr><tr><td>`columnName`</td><td>string</td><td>Name of the column (alternative to `columnId`)</td></tr><tr><td>`searchValue`</td><td>string</td><td>Search value</td></tr><tr><td>`lookupFieldFilter`</td><td>boolean</td><td>Controls the SQL join logic (see existing documentation)</td></tr></tbody></table>

---

### Response

#### HTTP 200 – Success

```
{
  "deletedRows": 127
}

```

<table id="bkmrk-field-type-descripti-3"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody><tr><td>`deletedRows`</td><td>integer</td><td>Number of rows actually deleted</td></tr></tbody></table>

#### HTTP 400 – Bad request

When `filters` is missing, is an empty array, or the body is empty:

```
{
  "message": "Request body must contain a non-empty 'filters' array.",
  "statusCode": 400
}

```

<p class="callout warning">**Security note:** An empty `filters` array `[]` is rejected with HTTP 400. An empty filter must under no circumstances lead to all rows of the table being deleted.</p>

#### HTTP 404 – Table not found

```
{
  "message": "Master data table with id 42 does not exist.",
  "statusCode": 404
}

```

---

### Example call

```
POST /api/v2/masterData/tables/42/rows/batch/delete/filter
Content-Type: application/json
Authorization: Bearer <token>

{
  "filters": [
    {
      "columnName": "companyCode",
      "operand": "=",
      "searchValue": "CRONUS"
    }
  ],
  "isAndConnectedQuery": true
}

```

**Expected result:** All rows of table 42 where `companyCode = "CRONUS"` are deleted. Rows of other companies remain untouched.

---

---

## Summary of the new endpoints

<table id="bkmrk-endpoint-method-func"><thead><tr><th>Endpoint</th><th>Method</th><th>Function</th></tr></thead><tbody><tr><td>`/masterData/tables/{tableId}/rows/batch`</td><td>`PATCH`</td><td>Upsert: insert or update rows without deleting other rows</td></tr><tr><td>`/masterData/tables/{tableId}/rows/batch/delete`</td><td>`POST`</td><td>Batch deletion by a list of internal IDs or external IDs</td></tr><tr><td>`/masterData/tables/{tableId}/rows/batch/delete/filter`</td><td>`POST`</td><td>Batch deletion by column filters</td></tr></tbody></table>

---

## Data types used (reference to existing schemas)

<table id="bkmrk-schema-already-prese"><thead><tr><th>Schema</th><th>Already present</th><th>Usage</th></tr></thead><tbody><tr><td>`MasterDataUploadResponse`</td><td>yes</td><td>Response for PATCH upsert</td></tr><tr><td>`MasterDataColumnFilter`</td><td>yes</td><td>Filter in the request body of endpoint 3</td></tr><tr><td>`KeyValueCollection`</td><td>yes</td><td>Single row as key-value pairs (internal)</td></tr></tbody></table>