Importing order flow
Map your exchange export to the canonical fields, dry-run it, then import it in batches.
You send your order flow as JSON rows keyed by canonical field names. Parsing the CSV and renaming its columns is done on your side; Seqlense then re-validates every row before storing it.
The flow is always the same:
| Step | Call | Writes? |
|---|---|---|
| 1. Read the field catalogue | GET /v1/orderbook/schema | no |
| 2. Dry-run a sample | POST /v1/orderbook/validate | no |
| 3. Open an import | POST /v1/orderbook/import/start | yes |
| 4. Send the rows | POST /v1/orderbook/import/batch, repeated | yes |
| 5. Close the import | POST /v1/orderbook/import/finish | yes |
Two record shapes
| Shape | One row is | Stored as |
|---|---|---|
ORDER_SNAPSHOT (default) | one order in its final state, what most exchange CSV exports look like | a NEW event at ts_submitted, plus the terminal event (FILL, PARTIAL_FILL, CANCEL, EXPIRE, REJECT) at ts_finished when the order ended |
ORDER_EVENT | one lifecycle event, what an audit trail looks like | one event |
An import has a single shape, fixed when you open it.
The canonical fields
GET /v1/orderbook/schema?shape=ORDER_SNAPSHOT returns the catalogue: each
field's type, whether it is required, the allowed values of enums, common
column-header aliases, and enum synonyms (COMPLETED -> FILLED...). Use it
to build your mapping.
| Field | Required | Notes |
|---|---|---|
client_id, order_id | always | kept verbatim |
venue | always | uppercased |
base, quote | always | the two assets, uppercased, must differ |
side | always | BUY, SELL |
order_type | always | LIMIT, MARKET, STOP, STOP_LIMIT, IOC, FOK |
price | always | in the quote asset, not negative |
quantity | always | in the base asset, greater than zero |
status | always | NEW, OPEN, PARTIALLY_FILLED, FILLED, CANCELED, EXPIRED, REJECTED |
ts_submitted | snapshot | when the order was placed |
ts_finished | snapshot, terminal status | when it ended; required for any status other than NEW / OPEN |
ts_event, event_type | event | event_type: NEW, REPLACE, PARTIAL_FILL, FILL, CANCEL, EXPIRE, REJECT |
filled_qty | optional | 0 to quantity. Without it a FILLED snapshot counts the whole quantity, any other status zero |
cancel_origin | optional | CLIENT, EXCHANGE, SYSTEM |
time_in_force | optional | GTC, IOC, FOK, DAY, GTD |
order_origin | optional | WEB, API, BOT, ADMIN |
liquidity_flag | optional | MAKER, TAKER |
account_id, fee, fee_currency, best_bid, best_ask, notional_usd | optional | best_bid may not exceed best_ask; notional_usd at the rate of the order's time |
The optional fields are worth mapping when you have them: each one makes a
detector more precise (the catalogue's unlocks text says how).
Enum values are case-insensitive but must be canonical. The server does not
apply the synonyms: translate COMPLETED to FILLED yourself, or the row is
rejected. Any key that is not a canonical field name also rejects the row, so a
typo like quantitiy never disappears in silence.
Parsing options
Sent with every validate and batch request, alongside rows:
| Option | Default | Meaning |
|---|---|---|
record_shape | ORDER_SNAPSHOT | must match the import's shape |
timezone | UTC | IANA zone (Europe/Paris) applied to timestamps without an offset |
decimal_separator | . | . or ,. The other character is read as a thousands mark |
first_row | 1 | file line of the first row, so errors point at your file |
Numbers. Send decimals as strings to keep every digit. Up to 18 decimal
places; more is rejected rather than rounded. With decimal_separator: ",",
"1.234,56" and "1 234,56" both read as 1234.56.
Timestamps. Accepted: epoch (seconds, milliseconds, microseconds or
nanoseconds, told apart by digit count), RFC 3339 (2026-04-20T09:44:02Z, its
offset wins over timezone), or a local time such as 2026-04-20 09:44:02,
2026-04-20T09:44:02.250, 2026/04/20 09:44:02, 20/04/2026 09:44 or
2026-04-20, read in timezone. A local time that falls in a daylight-saving
gap is rejected.
Getting timezone wrong is the classic failure: every timestamp lands hours
off and nothing looks wrong afterwards. That is what the dry run is for.
The flow
Dry-run a sample
POST /v1/orderbook/validate takes up to 500 rows, validates them exactly
like an import, and writes nothing.
{
"rows_seen": 2,
"rows_accepted": 1,
"rows_rejected": 1,
"events_would_write": 2,
"errors": ["row 3: status: 'COMPLETED' is not one of NEW, OPEN, PARTIALLY_FILLED, FILLED, CANCELED, EXPIRED, REJECTED - map it to one of those in the import profile"],
"profile": {
"events": 2,
"venues": [{ "value": "WHITEBIT", "count": 2 }],
"instruments": [{ "value": "BTC/USDC", "count": 2 }],
"statuses": [{ "value": "FILLED", "count": 1 }, { "value": "NEW", "count": 1 }],
"clients": [{ "value": "POSTG0000114", "count": 2 }],
"first_event": "2026-04-20T07:44:02+00:00",
"last_event": "2026-04-20T07:44:17+00:00"
}
}Read profile as well as errors: a constant venue in a multi-venue file, or
first_event two hours off, is a mapping that parses but means the wrong thing.
Open an import
POST /v1/orderbook/import/start with the shape and, optionally, a filename,
a venue label and the profile_id of the mapping you used. Send {} for the
defaults.
{ "import_id": "0b8f7a52-6a0e-4a8f-9d5e-3c1f0e2b7a41", "record_shape": "ORDER_SNAPSHOT" }Send the batches
POST /v1/orderbook/import/batch with the import_id, the parsing options and
up to 1000 rows. Repeat until the file is sent.
{
"import_id": "0b8f7a52-6a0e-4a8f-9d5e-3c1f0e2b7a41",
"rows_seen": 1000,
"rows_accepted": 998,
"rows_rejected": 2,
"events_written": 1994,
"errors": ["row 417: ts_finished: required because status is FILLED (a terminal state)",
"row 902: base and quote are both 'USDT'"]
}Bad rows are skipped and reported; the others are stored, and the call still
returns 200. Always check rows_rejected.
The whole request must stay under 2 MB. A bigger body is refused with
400 Empty or oversized request body...: send fewer rows per batch. 1000 plain
rows usually fit; wide rows may need smaller batches.
Re-sending a batch is safe, for example after a timeout. Event ids are derived from the event content, so duplicates collapse in storage instead of doubling volumes. The same holds for importing an overlapping file later.
Close the import
POST /v1/orderbook/import/finish with the import_id (and "status": "FAILED"
if you are abandoning it). The response is the import record, whose
events_written is now the number of distinct events actually stored.
{
"id": "0b8f7a52-6a0e-4a8f-9d5e-3c1f0e2b7a41",
"venue": "WHITEBIT",
"filename": "whitebit-2026-04.csv",
"record_shape": "ORDER_SNAPSHOT",
"status": "DONE",
"rows_received": 1000,
"rows_accepted": 998,
"rows_rejected": 2,
"events_written": 1994,
"errors": ["row 417: ts_finished: required because status is FILLED (a terminal state)",
"row 902: base and quote are both 'USDT'"],
"created_at": "2026-04-21 10:02:11",
"finished_at": "2026-04-21 10:02:19",
"rolled_back_at": "",
"events_removed": 0
}A closed import accepts no more batches. Only its first 50 rejection messages are kept; each batch response had all of its own.
Runnable example
This Node.js script (Node 18 or later, no dependencies) dry-runs two snapshot
rows, imports them and closes the import. Save it as import.mjs and run
SEQLENSE_API_KEY=sq_... node import.mjs. Use a development key to try it
without touching production data.
const BASE = "https://monitoring.seqlense.com/api/v1/orderbook";
const KEY = process.env.SEQLENSE_API_KEY;
// Rows already mapped to canonical names. In real use, build these from your CSV.
const rows = [
{ client_id: "POSTG0000114", order_id: "1776678242178", venue: "WHITEBIT",
base: "BTC", quote: "USDC", side: "SELL", order_type: "LIMIT",
price: "75235.92", quantity: "0.0001", status: "FILLED",
ts_submitted: "2026-04-20 09:44:02", ts_finished: "2026-04-20 09:44:17" },
{ client_id: "POSTG0000113", order_id: "1767011739000", venue: "BYBIT",
base: "ETH", quote: "USDC", side: "SELL", order_type: "LIMIT",
price: "3000", quantity: "0.003", status: "CANCELED", cancel_origin: "EXCHANGE",
ts_submitted: "2025-12-29 12:35:39", ts_finished: "2025-12-29 12:51:00" },
];
const options = { record_shape: "ORDER_SNAPSHOT", timezone: "UTC", decimal_separator: "." };
const BATCH = 1000;
async function call(path, body) {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`${path}: ${res.status} ${json.error}`);
return json;
}
// 1. Dry run on a sample (at most 500 rows). first_row 2 = line after the CSV header.
const check = await call("/validate", { ...options, first_row: 2, rows: rows.slice(0, 500) });
console.log("dry run:", check.rows_accepted, "ok,", check.rows_rejected, "rejected", check.errors);
if (check.rows_rejected > 0) process.exit(1);
// 2. Open the import.
const { import_id } = await call("/import/start", { record_shape: options.record_shape, filename: "demo.csv" });
// 3. Send the batches, then 4. close the import (FAILED if anything went wrong).
let status = "DONE";
try {
for (let i = 0; i < rows.length; i += BATCH) {
const r = await call("/import/batch", {
...options, import_id, first_row: 2 + i, rows: rows.slice(i, i + BATCH),
});
console.log(`batch at row ${2 + i}:`, r.events_written, "events,", r.rows_rejected, "rejected", r.errors);
}
} catch (e) {
status = "FAILED";
console.error(e.message);
}
const done = await call("/import/finish", { import_id, status });
console.log(done.status, done.events_written, "events stored, import", done.id);Two rows yield four events: NEW + FILL for the first, NEW + CANCEL for
the second.
Saving the mapping as a profile
A profile stores how a feed's columns map to canonical fields, so the next file
from the same feed needs no mapping. Its content is yours: the server stores
the profile object and gives it back, it never applies it. It only checks
record_shape and timezone when present, and caps the object at 64 KB.
curl -X POST -H "Authorization: Bearer sq_YOUR_API_KEY" \
-H "Content-Type: application/json" \
"https://monitoring.seqlense.com/api/v1/orderbook/profiles" \
-d '{
"name": "Whitebit export",
"venue": "WHITEBIT",
"profile": {
"record_shape": "ORDER_SNAPSHOT",
"timezone": "Europe/Paris",
"decimal_separator": ",",
"columns": { "ord_id": "order_id", "ord_amount": "quantity" }
}
}'{ "id": "5d7c1a3e-2f4b-4c6d-8e9f-0a1b2c3d4e5f", "name": "Whitebit export" }Saving again with the same name overwrites that profile and keeps its id.
GET /v1/orderbook/profiles lists them, DELETE /v1/orderbook/profiles?id=...
removes one. Pass the id as profile_id when opening an import to record which
mapping was used.
Following and undoing imports
GET /v1/orderbook/imports lists your imports, newest first (limit 1 to 200,
default 25), with their counters and status:
| Status | Meaning |
|---|---|
RUNNING | open, accepting batches. Still RUNNING long after created_at means it was never finished |
DONE | closed normally |
FAILED | closed as failed by the caller. Its stored events stay until you roll it back |
ROLLED_BACK | its events were removed |
Undoing an import
Imported a file with the wrong timezone? Roll back just that import:
curl -X POST -H "Authorization: Bearer sq_YOUR_API_KEY" \
-H "Content-Type: application/json" \
"https://monitoring.seqlense.com/api/v1/orderbook/imports/rollback" \
-d '{"import_id": "0b8f7a52-6a0e-4a8f-9d5e-3c1f0e2b7a41"}'{ "rolled_back": true, "import_id": "0b8f7a52-6a0e-4a8f-9d5e-3c1f0e2b7a41", "events_removed": 1994 }The import record stays, marked ROLLED_BACK, as an audit trail. It works on
RUNNING imports too. An unknown id returns 404; rolling back twice returns
400.
Because identical events share an id, when the same file was imported twice the stored copy belongs to the later import. Rolling back the earlier one may then remove nothing, and rolling back the later one removes events both carried. Re-import the file if that was not what you meant.
To clear everything instead, see Starting over.
Full request and response schemas are in the API reference.