---
title: "Import historical data"
description: "Backfill events from a previous analytics store with POST /v1/import, the write twin of /v1/export. Idempotent on row ids, NDJSON round-trips, retention caveats explained."
last_updated: "2026-08-21"
---

# Import historical data

Switching to CanaryLytics doesn't mean starting your charts from zero. `POST /v1/import` accepts historical events with their original timestamps, so months of existing data land in the same project your snippet reports to, and the dashboard shows one continuous history.

## Before you import: raise retention

Every night, a retention job deletes events older than your project's `retentionDays` (default 90). History you import past that window is accepted, then deleted the following morning. Raise retention on the dashboard's Settings page **before** importing, up to the 365-day maximum.

<Aside type="danger" title="Retention runs nightly">
  Import 12 months of history into a project with 90-day retention and the oldest 9 months are gone by tomorrow. Raise `retentionDays` first.
</Aside>

## The wire format

Import takes the same columns `/v1/export` emits. Only four fields are required per event:

| Field | Notes |
|---|---|
| `createdAt` | Original timestamp, ISO 8601. Future timestamps (beyond a 60-second clock-skew allowance) are rejected per row |
| `sessionId` | UUID; events sharing one count as one session |
| `eventType` | `page_view`, `link_click` or `custom` |
| `path` | The page path |

Everything else is optional: geo columns (`country`, `region`, `city`, …), classification flags (`isBot`, `isVpn`, `isInternal`), `locale`, `trafficSource`, UTM columns, and `userAgent`. Omit `browser` / `os` / `deviceType` and they are derived from `userAgent` at import time with the same classifier live ingest uses.

Send batches of up to 5000 as JSON, or as NDJSON with one event per line:

```bash
curl -X POST "https://api.analytics.canarycoders.es/v1/import" \
  -H "Authorization: Bearer cly_live_…" \
  -H "Content-Type: application/json" \
  -d '{"events":[{"createdAt":"2026-05-01T10:00:00Z","sessionId":"e073d59a-…","eventType":"page_view","path":"/pricing","country":"ES","locale":"es"}]}'
```

```json
{ "received": 1, "inserted": 1, "skippedInvalid": 0, "skippedDuplicate": 0 }
```

The response accounts for every row you sent. `skippedInvalid` rows failed validation (bad enum, future timestamp, malformed UUID); the rest inserted or deduplicated.

<Aside type="note" title="Full key only">
  Like every write, import needs a `cly_live_…` key. Read-only keys get `403 read_only_key`. Requests count against the shared per-key rate limit; on a `429`, wait for the `Retry-After` header and resend the same batch.
</Aside>

## Re-runs are safe: send row ids

Give each event a stable `id` (a UUID from your source store) and inserts become `ON CONFLICT DO NOTHING`: a crashed import can be restarted from the top, and already-imported rows report as `skippedDuplicate` instead of double-counting. Without ids, every row inserts fresh: run once, or dedupe yourself.

## Project-to-project migration

An NDJSON export round-trips directly, so moving a project (or seeding a staging copy) is two calls:

```bash
curl "https://api.analytics.canarycoders.es/v1/export?window=90d&format=ndjson&limit=5000" \
  -H "Authorization: Bearer cly_live_OLD…" -o events.ndjson

curl -X POST "https://api.analytics.canarycoders.es/v1/import" \
  -H "Authorization: Bearer cly_live_NEW…" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @events.ndjson
```

Exports carry row ids, so repeating a page is harmless. Follow the `X-Next-Cursor` header to page through exports larger than one request.

## From the SDK

```ts

const lytics = createClient({ apiKey: process.env.CANARYLYTICS_KEY });
const result = await lytics.importEvents(batch); // ≤ 5000 per call
console.log(`${result.inserted} inserted, ${result.skippedDuplicate} already there`);
```

## What history can and can't say

Imported rows are first-class events: they appear in the timeseries, top pages, countries, locales, sessions, and exports, and session counting works as long as your source had session ids. Two honest limits:

- **Traffic sources**: a session's source is classified from the referrer at ingest time. If your old store didn't keep a source label, imported sessions read as `direct`; that information can't be reconstructed later.
- **Cut over cleanly**: import history up to the moment the snippet went live on the domain. Overlapping windows double-count sessions, because the old store's session ids and the snippet's are different.

## Related

<CardGrid>
  <LinkCard title="REST API" description="The full /v1/import and /v1/export reference." href="/docs/api/rest/" />
  <LinkCard title="Keys & security" description="Why imports need the full key." href="/docs/keys/" />
</CardGrid>

## Sitemap

- [Docs sitemap](https://analytics.canarycoders.es/docs/sitemap.md)
- [Full documentation as a single file](https://analytics.canarycoders.es/llms-full.txt)
