Seqlense docs

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:

StepCallWrites?
1. Read the field catalogueGET /v1/orderbook/schemano
2. Dry-run a samplePOST /v1/orderbook/validateno
3. Open an importPOST /v1/orderbook/import/startyes
4. Send the rowsPOST /v1/orderbook/import/batch, repeatedyes
5. Close the importPOST /v1/orderbook/import/finishyes

Two record shapes

ShapeOne row isStored as
ORDER_SNAPSHOT (default)one order in its final state, what most exchange CSV exports look likea NEW event at ts_submitted, plus the terminal event (FILL, PARTIAL_FILL, CANCEL, EXPIRE, REJECT) at ts_finished when the order ended
ORDER_EVENTone lifecycle event, what an audit trail looks likeone 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.

FieldRequiredNotes
client_id, order_idalwayskept verbatim
venuealwaysuppercased
base, quotealwaysthe two assets, uppercased, must differ
sidealwaysBUY, SELL
order_typealwaysLIMIT, MARKET, STOP, STOP_LIMIT, IOC, FOK
pricealwaysin the quote asset, not negative
quantityalwaysin the base asset, greater than zero
statusalwaysNEW, OPEN, PARTIALLY_FILLED, FILLED, CANCELED, EXPIRED, REJECTED
ts_submittedsnapshotwhen the order was placed
ts_finishedsnapshot, terminal statuswhen it ended; required for any status other than NEW / OPEN
ts_event, event_typeeventevent_type: NEW, REPLACE, PARTIAL_FILL, FILL, CANCEL, EXPIRE, REJECT
filled_qtyoptional0 to quantity. Without it a FILLED snapshot counts the whole quantity, any other status zero
cancel_originoptionalCLIENT, EXCHANGE, SYSTEM
time_in_forceoptionalGTC, IOC, FOK, DAY, GTD
order_originoptionalWEB, API, BOT, ADMIN
liquidity_flagoptionalMAKER, TAKER
account_id, fee, fee_currency, best_bid, best_ask, notional_usdoptionalbest_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:

OptionDefaultMeaning
record_shapeORDER_SNAPSHOTmust match the import's shape
timezoneUTCIANA zone (Europe/Paris) applied to timestamps without an offset
decimal_separator.. or ,. The other character is read as a thousands mark
first_row1file 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.

import.mjs
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:

StatusMeaning
RUNNINGopen, accepting batches. Still RUNNING long after created_at means it was never finished
DONEclosed normally
FAILEDclosed as failed by the caller. Its stored events stay until you roll it back
ROLLED_BACKits 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.

On this page