API reference
Imports API
The Imports API manages background migrations of historical analytics into an Totallytics site. Jobs run asynchronously in monthly chunks.
Base URL: https://analytics.bitgate.dev. Download OpenAPI or read this page as Markdown.
Access
All import endpoints require a Firebase ID token in Authorization: Bearer <token>. Sign in, open Authentication, and use Copy access token. Totallytics has no static product API key; SimpleAnalytics credentials are source configuration, not Totallytics authentication.
GET /api/import-sources accepts any signed-in account. Every site-specific import endpoint requires the site's owner, even for public sites. Authentication is checked before the site lookup.
| 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 Totallytics origin. Use the normalized destination hostname returned by the Sites API; 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 planner rounds start down to the first day of its month. A request for 2026-08-15 through 2026-08-20 plans exports from August 1 through August 20. Use a month-boundary start to avoid including earlier days. The returned range_start still reflects the requested date. Each month has two chunks: pageviews, then events; the final month ends at the requested end.
Before queueing, creation tests the source credentials by requesting a recent pageview export. This can take time. 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.
read -rsp "Totallytics 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_KEYThe curl example requires Bash and jq; jq --arg safely encodes values, including quotes in credentials.
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://analytics.bitgate.dev/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports" \
-H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \
-H 'Content-Type: application/json' --data-binary @-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://analytics.bitgate.dev/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.
There is no idempotency key or cross-job deduplication ledger. Reimporting an overlapping period can duplicate records. 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 --fail-with-body --silent --show-error --max-time 20 \
-H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \
"https://analytics.bitgate.dev/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports/${EA_IMPORT_ID:?Set EA_IMPORT_ID}"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://analytics.bitgate.dev/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 not returned.
| Field | Type | Meaning |
|---|---|---|
id |
string | UUID job identifier |
site |
string | Destination Totallytics 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 | Checkpointed inserted records: pageviews, events and derived 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 |
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 retry duplicates are not reconciled: these counts are neither unique pageviews nor proof of exact or durable storage totals.
A job may have an error while still queued or running as the worker retries. Historic visitor identities are approximated from day, user agent, country and source hostname; report visitor counts need not match SimpleAnalytics exactly.
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 --fail-with-body --silent --show-error --max-time 20 \
-X POST \
-H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \
"https://analytics.bitgate.dev/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports/${EA_IMPORT_ID:?Set EA_IMPORT_ID}/cancel"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://analytics.bitgate.dev/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 stops later queued work, not an in-flight export or insert, and does not roll back rows. A retry skips checkpointed chunks, but partial writes in the current chunk and ambiguous insert retries can duplicate records. Retrying is not idempotent or exactly-once. It does not accept replacement credentials or dates; completed jobs cannot be retried.
Cancel and retry responses use the prior job snapshot with status overrides, so finished_at 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, missing configuration fields, Source hostname is invalid, or a provider validation error |
| 401 | sign in required: missing, invalid or expired token |
| 403 | not your site: authenticated caller is not the site owner |
| 404 | unknown site, or unknown import when the job is absent or belongs to another site |
| 409 | an import is already running for this site, import is not active for cancel, or only failed or canceled imports can be retried |
| 429 | too many imports today, try again tomorrow: rolling 24-hour creation limit reached |
| 500 | internal error; retry queue failure may leave the job queued, and retrying while another job is active can also produce this error |
| 502 | import queue unavailable, please try again: creation could not enqueue and cancels the new job |
Password configuration fields are never echoed in the job's config. Completion removes the stored api_key; failed and canceled jobs retain it for retry. 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; use the SimpleAnalytics migration guide to plan the wider cutover.