# Imports API

The Clicktag Imports API copies historical pageviews and events from Simple Analytics into a site in monthly background chunks, up to 1,826 days per job. Starting a job needs the site owner's Firebase ID token, and each owner can start 20 per rolling 24 hours.

Base URL: `https://clicktag.io`. [Download OpenAPI](/openapi.json) or [read this page as Markdown](/docs/imports.md).

## Access

Starting, canceling and retrying imports need a Firebase ID token in `Authorization: Bearer <token>`. Sign in, open [Authentication](/docs/authentication), and use **Copy access token**. Reading sources and jobs also works with a [management key](/docs/authentication#management-keys) that has the `read` scope; on the `POST` routes a management key gets `403` `management keys cannot call this endpoint`. Site API keys (`tt_...`) do not work here; SimpleAnalytics credentials are source configuration, not Clicktag authentication.

`GET /api/import-sources` accepts any signed-in account or management key with `read`. Every site-specific import endpoint requires the site's owner, **even for public sites**. Authentication is checked before the site lookup. Creating an import also requires [domain verification](/docs/sites#verify-ownership); listing, inspecting, canceling and retrying jobs do not add a verification gate.

| Method | Route                                          | Result                                     |
| ------ | ---------------------------------------------- | ------------------------------------------ |
| GET    | `/api/import-sources`                          | Supported sources and configuration fields |
| POST   | `/api/sites/{hostname}/imports`                | Validate, create and queue a job           |
| GET    | `/api/sites/{hostname}/imports`                | Latest 50 jobs for the site                |
| GET    | `/api/sites/{hostname}/imports/{jobId}`        | One job's current state                    |
| POST   | `/api/sites/{hostname}/imports/{jobId}/cancel` | Cancel a queued or running job             |
| POST   | `/api/sites/{hostname}/imports/{jobId}/retry`  | Re-queue a failed or canceled job          |

Responses are JSON with `Cache-Control: no-store`. Product API responses have no cross-origin CORS headers; call from a server or the Clicktag origin. Use the normalized destination hostname returned by the [Sites API](/docs/sites); path lookups do not lowercase it.

TypeScript examples run server-side on Node 20+: save a snippet as `example.mts`, set its environment variables, then run `npx tsx example.mts`. No example automatically retries a POST.

## Available sources

`GET /api/import-sources` returns `{ "sources": [...] }`. Each source has `id`, `label`, `description` and `configFields`. A configuration field has `key`, `label`, `type` (`text`, `password` or `date`), `required`, and optional `placeholder` and `help` strings.

The current source is `simpleanalytics`, labeled **SimpleAnalytics**:

| Configuration key | Type     | Required | Meaning                                                                 |
| ----------------- | -------- | -------- | ----------------------------------------------------------------------- |
| `user_id`         | text     | Yes      | User ID from SimpleAnalytics dashboard → Account → API                  |
| `api_key`         | password | Yes      | SimpleAnalytics API key                                                 |
| `source_hostname` | text     | Yes      | Hostname registered in SimpleAnalytics; may differ from the destination |

## Start an import

`POST /api/sites/{hostname}/imports` requires `source`, `config`, `start` and `end`. Set `source` to `simpleanalytics` and supply all three configuration strings above. Strings are trimmed and truncated to 512 characters; unknown configuration keys are ignored. `source_hostname` is lowercased and must be a hostname containing a dot, without a scheme, port or path.

Dates are inclusive UTC dates in `YYYY-MM-DD` format. `start` must be on or after `2010-01-01`, `start <= end`, and the difference `end - start` cannot exceed 1,826 days. There is no future-end cutoff. The dates are checked before the rest of the body, so a missing or non-JSON body also returns `invalid date range`.

The planner preserves the requested date boundaries. Each month has two chunks: pageviews, then events; the first chunk starts on `start` and the final month ends on `end`.

Before queueing, creation checks the daily limit and the one-active-job rule, then tests the source credentials with one request for a recent pageview export. The test gives up after 20 seconds, and a failed test returns `400`. **`201` means queued, not import finished.** Check job progress and actual reports before considering the migration complete.

Read credentials without putting them in shell history. Export `EA_HOSTNAME` for your registered destination, `SA_HOSTNAME` for the source, and `EA_IMPORT_START` / `EA_IMPORT_END` for your chosen dates. These examples create a real job when used with valid credentials.

```bash
read -rsp "Clicktag access token: " TT_TOKEN; printf '\n'
read -rsp "SimpleAnalytics user ID: " SA_USER_ID; printf '\n'
read -rsp "SimpleAnalytics API key: " SA_API_KEY; printf '\n'
export TT_TOKEN SA_USER_ID SA_API_KEY
```

The curl example requires Bash and `jq`; `jq --arg` safely encodes values, including quotes in credentials.

```curl
set -o pipefail
jq -n \
  --arg user_id "${SA_USER_ID:?Set SA_USER_ID}" \
  --arg api_key "${SA_API_KEY:?Set SA_API_KEY}" \
  --arg source_hostname "${SA_HOSTNAME:?Set SA_HOSTNAME}" \
  --arg start "${EA_IMPORT_START:?Set EA_IMPORT_START}" \
  --arg end "${EA_IMPORT_END:?Set EA_IMPORT_END}" \
  '{source:"simpleanalytics",config:{user_id:$user_id,api_key:$api_key,source_hostname:$source_hostname},start:$start,end:$end}' |
  curl --fail-with-body --silent --show-error --max-time 300 \
    -X POST "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports" \
    -H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \
    -H 'Content-Type: application/json' --data-binary @-
```

```typescript
const {
  TT_TOKEN,
  EA_HOSTNAME,
  SA_USER_ID,
  SA_API_KEY,
  SA_HOSTNAME,
  EA_IMPORT_START,
  EA_IMPORT_END,
} = process.env;
if (
  !TT_TOKEN ||
  !EA_HOSTNAME ||
  !SA_USER_ID ||
  !SA_API_KEY ||
  !SA_HOSTNAME ||
  !EA_IMPORT_START ||
  !EA_IMPORT_END
) {
  throw new Error(
    "Set the token, destination, source credentials, hostname and dates",
  );
}

const response = await fetch(
  `https://clicktag.io/api/sites/${encodeURIComponent(EA_HOSTNAME)}/imports`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${TT_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      source: "simpleanalytics",
      config: {
        user_id: SA_USER_ID,
        api_key: SA_API_KEY,
        source_hostname: SA_HOSTNAME,
      },
      start: EA_IMPORT_START,
      end: EA_IMPORT_END,
    }),
    signal: AbortSignal.timeout(300_000),
  },
);
if (!response.ok) {
  throw new Error(`Create import ${response.status}: ${await response.text()}`);
}
console.log(await response.json());
```

The response is the job object directly. Save its `id` as `EA_IMPORT_ID` to read progress. At most 20 new jobs per owner can be created in a rolling 24-hour window across all sites and statuses; the limit returns `429`. Only one `queued` or `running` job may exist per site; another creation returns `409`. Retrying the same job is not a new creation.

Import creation has no request idempotency key. The sink preserves stable SA source IDs and skips existing source records across imports; overlapping live TT data has unrelated IDs and still requires a deliberate replacement cutoff. After a timeout or ambiguous response, inspect the site's jobs before attempting another POST.

## Read progress

`GET /api/sites/{hostname}/imports` returns `{ "imports": [...] }`, ordered by `created_at` descending, with at most 50 entries. There are no pagination, cursor or offset parameters.

`GET /api/sites/{hostname}/imports/{jobId}` returns the job object directly. To list jobs instead, remove `/{jobId}` from the request below. Source discovery uses the same bearer header with `/api/import-sources`.

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  -H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \
  "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports/${EA_IMPORT_ID:?Set EA_IMPORT_ID}"
```

```typescript
const { TT_TOKEN, EA_HOSTNAME, EA_IMPORT_ID } = process.env;
if (!TT_TOKEN || !EA_HOSTNAME || !EA_IMPORT_ID) {
  throw new Error("Set TT_TOKEN, EA_HOSTNAME and EA_IMPORT_ID");
}
const response = await fetch(
  `https://clicktag.io/api/sites/${encodeURIComponent(EA_HOSTNAME)}/imports/${encodeURIComponent(EA_IMPORT_ID)}`,
  {
    headers: { Authorization: `Bearer ${TT_TOKEN}` },
    signal: AbortSignal.timeout(20_000),
  },
);
if (!response.ok) {
  throw new Error(`Import ${response.status}: ${await response.text()}`);
}
console.log(await response.json());
```

## Job object

Create, get, cancel and retry return this object. List entries use the same shape; `owner_uid` and `updated_at` are never returned.

| Field           | Type           | Meaning                                                                                  |
| --------------- | -------------- | ---------------------------------------------------------------------------------------- |
| `id`            | string         | UUID job identifier                                                                      |
| `site`          | string         | Destination Clicktag hostname                                                       |
| `source`        | string         | Source ID, currently `simpleanalytics`                                                   |
| `source_label`  | string         | Source display label, currently `SimpleAnalytics`                                        |
| `status`        | string         | `queued`, `running`, `completed`, `failed` or `canceled`                                 |
| `range_start`   | string         | Requested inclusive start date, `YYYY-MM-DD`                                             |
| `range_end`     | string         | Requested inclusive end date, `YYYY-MM-DD`                                               |
| `chunks_total`  | integer        | Planned pageview and event chunks                                                        |
| `chunks_done`   | integer        | Fully checkpointed chunks                                                                |
| `rows_imported` | number         | Records of checkpointed chunks sent to storage, including ones already stored: pageviews, events and duration/scroll append rows |
| `rows_skipped`  | number         | Checkpointed source records skipped for another hostname or an event without a name      |
| `error`         | string or null | Latest worker error, up to 500 characters; cleared when a chunk succeeds                 |
| `config`        | object         | Non-password fields: `user_id` and `source_hostname`; never `api_key`                    |
| `created_at`    | string         | ISO date-time of creation                                                                |
| `finished_at`   | string or null | ISO date-time of completion, failure or cancellation; normally null while active         |

Request and returned range dates use `YYYY-MM-DD`, for example `2026-08-01`. Progress updates only after a whole chunk finishes. Partial writes may not appear in counters, and repeated source rows can be skipped at insertion: these counters are neither unique pageviews nor proof of exact storage totals.

A job may have an `error` while still `queued` or `running` as the worker retries: each chunk is tried up to 4 times, about a minute apart, before the job fails with the last error. A rejected API key, or any other SimpleAnalytics `4xx` except `429`, fails the job right away. Imported pageviews count toward visitors through their exported `is_unique` flags; no synthetic visitor identities are invented. Duration and scroll attach to their original pageview and inherit its date and bot status. Browser and OS labels use the same normalization as live collection; imported country retains SA's timezone-based value.

## Cancel or retry

Both actions accept a POST with no body and return `200` with the job object.

| Action    | Allowed state          | Effect                                                                   |
| --------- | ---------------------- | ------------------------------------------------------------------------ |
| `/cancel` | `queued` or `running`  | Sets status to `canceled`                                                |
| `/retry`  | `failed` or `canceled` | Sets status to `queued`, clears the error and resumes from `chunks_done` |

The examples cancel a job. To retry an eligible job, replace `/cancel` with `/retry` after checking its state.

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  -X POST \
  -H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \
  "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports/${EA_IMPORT_ID:?Set EA_IMPORT_ID}/cancel"
```

```typescript
const { TT_TOKEN, EA_HOSTNAME, EA_IMPORT_ID } = process.env;
if (!TT_TOKEN || !EA_HOSTNAME || !EA_IMPORT_ID) {
  throw new Error("Set TT_TOKEN, EA_HOSTNAME and EA_IMPORT_ID");
}
const response = await fetch(
  `https://clicktag.io/api/sites/${encodeURIComponent(EA_HOSTNAME)}/imports/${encodeURIComponent(EA_IMPORT_ID)}/cancel`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${TT_TOKEN}` },
    signal: AbortSignal.timeout(20_000),
  },
);
if (!response.ok) {
  throw new Error(`Cancel import ${response.status}: ${await response.text()}`);
}
console.log(await response.json());
```

Cancellation does not roll back rows. A chunk still being exported is discarded; a chunk already being written finishes first, and the cancel request waits for it. A retry skips checkpointed chunks; the sink also skips stable SA records already inserted by a partial attempt. This does not reconcile unrelated live collector IDs or restore interrupted job counters exactly. It does not accept replacement credentials or dates; completed jobs cannot be retried. Failed and canceled jobs keep their `api_key` for 7 days, then it is deleted and retry returns `409`; start a new import instead.

Cancel and retry responses use the prior job snapshot with status overrides, so `finished_at` and the counters can be stale. Poll the GET endpoint for the persisted state.

## Errors and credentials

Errors use `{ "error": "message" }`.

| Status | Message or condition                                                                                                                      |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid date range`, `unknown import source`, a missing field such as `User ID is required`, `Source hostname is invalid`, or a failed credential test such as `SimpleAnalytics returned 401: <response text>` |
| 401    | `sign in required`: missing, invalid or expired token; `invalid or revoked management key`                                               |
| 403    | `Install your tracking script to verify this site first`: creating an import requires a verified site; `management keys cannot call this endpoint` on the `POST` routes; `scope read required` for a key without `read` |
| 404    | `unknown site`: hostname is absent or not owned by the caller; `unknown import`: job is absent or belongs to another site |
| 409    | `an import is already running for this site` on create or retry, `import is not active` for cancel, `only failed or canceled imports can be retried`, or `The API key for this import was deleted. Start a new import.` for a retry after 7 days |
| 429    | `too many imports today, try again tomorrow`: rolling 24-hour creation limit reached                                                      |
| 500    | `internal error`                                                                                                                          |
| 502    | `import queue unavailable, please try again`: the job could not be queued. Creation cancels the new job, which still counts toward the daily limit and can be retried; retry marks the job `failed` with error `import queue unavailable` |

Password configuration fields are never echoed in the job's `config`. Completion removes the stored `api_key`. Failed and canceled jobs keep it for 7 days so they can be retried, then it is deleted. The non-password fields remain visible. Error text may include upstream response snippets and is not generally secret-redacted.

After an enqueue error, check job state before taking another action. Compare actual data with the [Stats API](/docs/stats); use the [SimpleAnalytics migration guide](/docs/migrate-simple-analytics) to plan the wider cutover.
