# Historical import

> Send older readings of a device to the Proxus historian in small batches over HTTP, with a key that makes every batch safe to retry.

Source: https://proxus.io/docs/develop/historical-import

At the end of this guide, your program sends readings that a device recorded in the past (from a data logger, a
CSV export or a machine's own memory) to the Proxus historian. They are stored next to the device's live values and
appear in trends, reports and the [Telemetry API](https://proxus.io/docs/develop/telemetry-api).

## Before you start

You need:

- The device in Proxus, enabled, on a running edge gateway. Note its **ID** (the `Oid` in the
  [REST API](https://proxus.io/docs/develop/rest-api)) and its **Token** (shown on the device page).
- Network access to the gateway's HTTP port, `8083` by default (`Server-Port` in `[Service]`).
- Historical import turned on. It is off in the delivered configuration; see the next section.

## Turn historical import on

Set these keys, then restart the hub and the gateways:

| Where | Key | Value |
| --- | --- | --- |
| Hub and every gateway that receives imports | `HistoricalImport__Enabled` | `true` |
| The gateway that writes the Proxus historian (the hub's **SYSTEM** gateway in a standard installation) | `HistoricalImport__StorageEnabled` | `true` |

While import is off, the endpoints answer `503`.

## Step 1: Send a batch

```text
POST http://<gateway>:8083/api/v1/devices/<device ID>/historical/batches
X-IoT-Token: <device token>
Idempotency-Key: lys-packer-04-2026-10-01-0001
Content-Type: application/json
```

```json
{
  "records": [
    { "key": "Temperature", "time": "2026-10-01T06:00:00.000Z", "type": "Float", "value": 24.6, "unit": "°C" },
    { "key": "Speed",       "time": "2026-10-01T06:00:00.000Z", "type": "UShort", "value": 82, "unit": "rpm" },
    { "key": "Running",     "time": "2026-10-01T06:00:00.000Z", "type": "Bool", "value": true }
  ]
}
```

| Field | Rules |
| --- | --- |
| `records` | 1 to 50 records. |
| `key` | The tag name, up to 256 characters. |
| `time` | ISO 8601 with `Z` or an offset such as `+02:00`, whole milliseconds at most, not in the future, not before 1900. |
| `type` | Exactly one of `Bool`, `Short`, `UShort`, `Int`, `UInt`, `Float`, `Long`, `ULong`, `Double`, `String`, `Bytes`, `DateTime` (case matters). |
| `value` | A JSON value that fits `type`: `true`/`false` for `Bool`, a number for the numeric types, a string for `String` (up to 4096 characters), Base64 text for `Bytes`, an ISO 8601 string for `DateTime`. `Long` and `ULong` must be within ±9007199254740991. |
| `unit` | Optional, up to 64 characters. |

Other fields in the body are refused. The whole body may be at most 256 KiB.

The **Idempotency-Key** names the batch: 8 to 128 letters, digits, `-`, `_` or `.`. Sending the same key with the
same body again is safe and returns the first answer. Sending the same key with a different body is refused. Use
one key per batch, for example the source file name and a batch number.

## Step 2: Read the answer

A new batch is answered with `202 Accepted`, a repeated one with `200`. The `Location` header points at the batch.

```json
{
  "jobId": "5b0e6a5c-6f4e-4a0e-9a59-3f8f0f8c2d11",
  "state": "Pending",
  "results": [
    { "index": 0, "state": "Pending" },
    { "index": 1, "state": "Pending" },
    { "index": 2, "state": "Rejected", "error": "InvalidTypeOrValue" }
  ],
  "storageDataset": "DeviceRawData"
}
```

Each record has its own result, in the order you sent them. Rejected records are not stored; the others are.

| Record error | Cause |
| --- | --- |
| `InvalidRecord` | Missing or too long `key`, too long `unit`, or no `value`. |
| `InvalidTimestamp` | `time` missing, in the wrong format, in the future, before 1900 or finer than a millisecond. |
| `InvalidTypeOrValue` | Unknown `type`, or a `value` that does not fit it. |
| `MeasurementConflict` | The batch has the same tag and time twice with different values. The same tag, time and value twice is marked `Duplicate` instead. |

## Step 3: Follow the batch

```text
GET http://<gateway>:8083/api/v1/devices/<device ID>/historical/batches/<jobId>
X-IoT-Token: <device token>
```

| Batch `state` | Meaning |
| --- | --- |
| `Pending` | Accepted, being written. |
| `Completed` | Every accepted record is stored. |
| `CompletedWithErrors` | Done, and at least one record was rejected. |
| `NeedsReconciliation` | The storage did not confirm some records. Their result is `Uncertain`. Send the same batch again later with the same key. |

Record states after writing: `Stored`, `Duplicate` (the same value for that tag and time is already stored),
`Rejected` or `Uncertain`.

## Answers without a batch

| Status | Body or header | Meaning |
| --- | --- | --- |
| `400` | `{"error":"InvalidIdempotencyKey"}` | The `Idempotency-Key` header is missing or not in the allowed form. |
| `400` | `{"error":"InvalidJson"}` or `{"error":"InvalidBatch"}` | The body is not JSON, has unknown fields, or has no records or more than 50. |
| `401` | | Missing `X-IoT-Token`, or the token does not belong to this device ID, or the device is disabled. |
| `404` | | No batch with this ID for this device. |
| `409` | `{"error":"IdempotencyConflict"}` | This key was used before with a different body. |
| `413` | | The body is larger than 256 KiB. |
| `415` | | `Content-Type` is not JSON. |
| `429` | `Retry-After: 5` | The gateway is busy (8 requests at a time), or too many batches are still being written (8 per device, 64 in total). Wait and send again with the same key. |
| `503` | | Import is turned off, or the hub cannot be reached. Send again later with the same key. |

## Example in Python

```python
import time
import requests

GATEWAY = "http://localhost:8083"
DEVICE_ID = 69
DEVICE_TOKEN = "<the device's token>"

def send_batch(key, records):
    while True:
        response = requests.post(
            f"{GATEWAY}/api/v1/devices/{DEVICE_ID}/historical/batches",
            json={"records": records},
            headers={"X-IoT-Token": DEVICE_TOKEN, "Idempotency-Key": key},
            timeout=30,
        )
        if response.status_code in (429, 503):
            time.sleep(int(response.headers.get("Retry-After", "5")))
            continue
        response.raise_for_status()
        return response.json()

batch = [
    {"key": "Temperature", "time": "2026-10-01T06:00:00.000Z", "type": "Float", "value": 24.6, "unit": "°C"},
    {"key": "Temperature", "time": "2026-10-01T06:01:00.000Z", "type": "Float", "value": 24.9, "unit": "°C"},
]
job = send_batch("lys-packer-04-2026-10-01-0001", batch)
print(job["state"], [r["state"] for r in job["results"]])
```

> **Note:**
Imported values are stored with the device's current name. They do not run through pipelines, rules or outbound
targets, and they are not published on the UNS topics: they are history, not live data.

## What's next

- [Telemetry API](https://proxus.io/docs/develop/telemetry-api): check what was imported.
- [Trend Workspace](https://proxus.io/docs/monitor/trend-workspace): see the imported values next to live ones.
- [Network ports and endpoints](https://proxus.io/docs/reference/ports).
