FAQ: straight answers to awkward questions
The admin panel is currently available in Russian only; menu items and button labels in this guide are translated.
What happens if ClickHouse goes down?
The click path does not stop. Click records accumulate in an in-memory ring buffer and are batched into ClickHouse every 1-2 seconds; if ClickHouse is unavailable, the record goes to a WAL file on disk (JSON lines, with fsync), and a separate goroutine periodically tries to read the WAL back and send what has piled up to ClickHouse — once the connection is back, the data is loaded automatically, with no manual intervention. The replay is idempotent: if a click had already been written and the WAL replays it again anyway, there will be no duplicates in the table.
The edge case: if both the ring buffer and the WAL spill queue overflow at the same time (an extreme load spike while ClickHouse is unavailable), the click is lost, and this shows up as growth of a dedicated counter in the metrics (not silently).
⚠️ A separate finding, already closed by default: the tracker's connection to ClickHouse
is asynchronous (async_insert=1, wait_for_async_insert=0) — the server answers "ok" as soon
as it has put the batch into ITS OWN queue, not when it has physically written it to disk. If
ClickHouse crashed exactly in that window (verified by deliberately sending SIGKILL to the
container during a load test), the batch vanished without a trace — neither in ClickHouse nor in
the WAL, because the code already considered the insert successful. The default since
2026-09-24 is INOTRACK_CLICK_CONFIRM_INSERT=true: click inserts wait for physical
confirmation before the batch is considered delivered (the cost is not response latency for
the visitor, since batches are written in the background, but the throughput of the
background flush: ~130-150 thousand inserts/s instead of ~500-640 thousand/s per connection;
the target 41,000 clicks/s still fits with headroom). Turning it off (=false) is a
deliberate operator choice for throughput above typical load, not the default behavior: a
lost click is lost client money.
The flip side: postbacks degrade worse when ClickHouse is unavailable. Conversion intake queries ClickHouse on every request (anti-fraud statistics) — if it is unavailable, the postback handler panics (caught by a per-request recover, so the process as a whole does not crash), but that particular incoming postback is cut off with a dropped connection instead of the graceful degradation the click path gets. This is a known limitation, and it is being fixed separately from the click path.
What happens if Postgres is unavailable?
The critical click path (/go/{token}, /api/click) does not touch Postgres at all — the
flow config is read from Redis. If it is Redis (not Postgres) that is unavailable, resolving
the flow fails, but the click is still recorded and sent to the global fallback URL, because
the traffic has been paid for and must not be lost.
Postgres is needed by the management side: entity CRUD via the API, reports (partly), finance, postback intake (resolving the endpoint by token is an SQL query for every incoming postback). If Postgres is unavailable, this part responds with errors, but click intake continues.
Are clicks lost on restart/update?
On a normal shutdown (tracker-ctl update, docker compose restart and the like), the
process waits up to 20 seconds (configurable with INOTRACK_CLICK_SHUTDOWN_FLUSH_MS) to
fully drain the in-memory buffer — either write it to ClickHouse or (if ClickHouse is
unavailable at that exact moment) spill the remainder to the WAL on disk rather than discard
it. tracker-ctl update additionally waits for the updated instance to become healthy and
automatically rolls the version back if it does not.
On an abnormal process termination (kill -9, OOM, host crash), the contents of the
in-memory ring buffer that had not yet been written or spilled to the WAL are lost — this is
an unavoidable consequence of the click path not writing to Postgres/disk synchronously on
every request (otherwise the stated throughput would not be possible).
How do I change the tracker domain if it gets blocked?
tracker-ctl domain add new-domain.example.com
# point the new domain's A record at this server, wait for the TLS certificate
tracker-ctl domain rm blocked-domain.example.com
The tracker domain pool is stored in a single .env variable (TRACKER_DOMAINS) and served
by the same Caddy — adding/removing is applied via caddy reload without restarting the
service. You cannot remove the last domain from the pool without adding a new one —
otherwise click intake would be left with no domain at all.
Since 2026-09-26 this is not the only way: a domain added in the panel under "Tracker domains" is served as soon as its A/CNAME points at the server — Caddy issues the certificate via on-demand TLS after asking the core whether the domain is in the pool (in k3s the core manages the pool's Ingress itself). "Make primary" changes the domain in all NEW links — of flows, smartlinks, landing pages and funnels; links already sent out stay on their domain for as long as it is alive. The panel marks a domain as "not served by this server" if the last health check got a response from something other than this tracker, and such a domain cannot be made primary. Details are in the operations guide, section "Adding/removing a tracker domain".
Does the anti-scraping gateway block my traffic by default?
No. Since 2026-09-24 the guard module is connected to the click path (/go/{token},
/api/click) and evaluates every click, but the "block" action fires only if the request
matches an explicit rule (guard_rules — UA/IP/CIDR/ASN/referer/geo/bot module verdict)
or an explicit list (your own IP/CIDR/UA/ASN blocklist, or a feed of datacenter
networks/scrapers if you have connected one). Without a single configured rule or list, the
anti-scraping gateway blocks no one — verified search crawlers (Google/Bing/Yandex) always
pass, before any rules, and all other traffic that does not match a rule passes too, even if
the bot module internally flagged it as suspicious (the flag is still written to the click,
it is visible in reports and you can decide manually).
⚠️ It was not always like this — this is a fixed bug, not the original design: before the fix,
any unverified "bot" verdict (datacenter IP, proxy, a frequency spike from a subnet — not just
an explicit list) was blindly blocked by the site's default action, even if no rule matched.
Since the site_id is almost never known on the /go/{token} path (ad networks do not pass
it), this meant the default applied to practically every click — mobile carriers with NAT
addresses, office networks, any IP from a connected feed got an empty response instead of a
redirect, and paid traffic was silently lost. Now, without a known site_id (that is, almost
always on the click path), only explicit rules/lists apply, and the site default
(sites.guard_action) takes part only where the site_id is actually known — on the reverse
proxy in front of landing pages/network sites.
To test a rule before going live, use POST /api/internal/guard/test in the panel ("Antibot"
section) or directly: plug in a UA/IP/referer/geo and see which rule fires and what is
returned, without writing to the log and without any risk to real traffic.
How do I buy a license — do I need to contact anyone?
No, purchase is self-service: https://inotrack.run/en/buy/ — plan, term (1, 3 or 12
months), e-mail → order page with the address and amount in USDT (TRC20) → after payment and
19 network confirmations the key appears on the same page (once) and arrives by e-mail from
[email protected]. Step by step, including what to do on underpayment or late payment:
the install guide, "How to get a license key". Renewal is only from your
account (https://inotrack.run/en/account/, sign in via the link from the e-mail): the current
license's term is extended, the key stays the same.
How does the trial work?
7 days, Starter plan limits, one instance, no payment — only an e-mail is needed. You can get
it at https://inotrack.run/en/trial/ or right in the installer (Enter instead of a key, or
--trial-email, see the install guide). Restrictions:
- One trial per e-mail and one per server. The response to a request is always the
same — the site does not reveal whether a trial has already been issued to that address;
if it has, there simply will be no new e-mail. A repeat trial on the same server with a
different e-mail will be rejected at activation (
trial_machine_used). - A trial license cannot be extended, but you can buy a plan for the same key: in your account (next question). No need to reinstall the tracker or change the key.
- After 7 days the trial license expires the same way as any other (see "What happens when the license expires?" below).
How do I switch from a trial key to a purchased one?
The easiest way is to buy a plan for the trial key itself. In your account https://inotrack.run/en/account/ (sign in via the link from an e-mail to the same address), the trial license has a "Buy and keep my key" button: choose a plan and term, pay the order — and the same license becomes paid (plan, limits, term — from the moment of payment). The key, data, servers and free inotrack domains stay the same: the tracker picks up the new plan by itself the next time it contacts the license server, usually within 15 minutes. It also works for an already expired trial. The "Buy" button in the trial banner in the tracker panel leads to the same place, and so does the "Buy a plan for that license" link on https://inotrack.run/en/buy/. There can be only one unpaid order for a trial license: clicking "Buy and keep my key" again with the same plan and term opens the same order (the address and amount are unchanged; if part of the amount has arrived, with the remainder due), and you can choose a different plan once the unpaid order expires: one hour after it was created, 20 minutes for a plan with a limited number of seats, one day after the underpayment for an underpaid order. The account page shows which order is pending and how much time is left.
If you bought a new license instead (via /en/buy/), it has its own key, which you need to set on the server:
# 1. the new key goes into INOTRACK_LICENSE_KEY
sudo nano /opt/inotrack/.env
# 2. the tracker-ctl activation cache is tied to the previous key; remove it, otherwise refresh
# will keep renewing the trial license
sudo rm /opt/inotrack/state/license.json
# 3. activation with the new key (and login to the update registry with the same key)
sudo tracker-ctl license refresh
# 4. recreate the containers with the new .env; the tracker will see the key change and re-activate
cd /opt/inotrack && sudo docker compose up -d
⚠️ Free inotrack domains are tied to the key they were issued for. Domains obtained with
the trial key are deleted 30 days after the trial license expires — and links on them will
stop working. After switching, get domains for the new key (tracker-ctl domain claim --purpose clicks, and --purpose panel if needed; these are different names), make the new
tracker domain primary under "Domains" and update the links in your ad accounts while the old
ones are still alive. Your own domains are not affected.
How do I move a license to another server?
Without support — in your account https://inotrack.run/en/account/: in the license's "Servers" list, next to the active server, there is a "Release slot" button. The released server stops renewing the license and switches to restricted mode (data is not deleted), and the license can be activated right away on another server with the same key. Self-service releases are limited to 3 per 30 days per license; if you have used them up, write to support and we will move it manually.
Migration order:
- Stop the tracker on the old server (
cd /opt/inotrack && sudo docker compose down). - Release its slot in your account.
- Start the new server with the same key (installation — the
install guide, data migration — the
operations guide,
tracker-ctl backup/restore).
⚠️ The order matters. A running old tracker will not take the slot back after it was
released — it remembers that the slot was released. But if the old server is recreated
without its saved state (a clean install with the same key, the /var/lib/inotrack/license
directory lost) and starts before the new one, it activates again as an ordinary machine
and takes the slot again — no room is left for the new server, and the release has already
been used up. So first stop the old one, then release the slot, and only then start the new
one.
Which e-mails arrive after purchase and during the trial, and how do I unsubscribe?
Service e-mails always arrive: the key, the order, the sign-in link for your account, license term reminders. Besides them, there are tip e-mails — they help you get to the first click:
- After purchase (for each paid license, counted from payment): on the day of payment — "how to install" (the install command, the install guide, the quickstart, migrating from Keitaro); 2 days later, if the key has never been activated yet — "need help with installation?" with a link to support; from day 5, if the tracker is installed and online but has had no clicks in the last 24 hours — "connect your first flow" (postbacks, macros). If a tracker is already running on this key, the first two e-mails are not sent.
- During the trial: on day 1, 3 and 6 and after it ends; on day 2, if the tracker is installed but has had no clicks in 24 hours — the same "connect your first flow".
No more than one tip e-mail per day per address; the language is the one in which the order
or trial was placed. The license server learns that there were "no clicks in 24 hours" from
the usage.clicks_24h counter in the heartbeat (see "What telemetry does the instance send
to the license server?") — from the servers of the license the e-mail is about: a trial
tracker with no clicks will not trigger an e-mail about a paid license. A tracker that does
not send this counter or does not come online gets no e-mail about the flow.
To unsubscribe, use the link at the bottom of any tip e-mail (or the "Unsubscribe" button in your mail client). Unsubscribing applies to all tip e-mails at once; service e-mails will keep coming.
USDT payment: what if the amount, network or timing is wrong?
- Network and token — only USDT on the TRON network (TRC20). The shop will not see a payment on another network (ERC20, BEP20) or in another token — write to support (https://inotrack.run/en/support/) with the transaction hash; it will not be credited automatically.
- Underpayment — the order switches to "Partial payment received" and waits 24 hours for the rest to be paid to the same address.
- Overpayment — the order is credited as paid, the shop records the excess on its side (it is not shown on the order page); to get it refunded or credited, contact support with the order number.
- Missed the 60 minutes — the full amount sent to the order's address within 7 days is still credited. If you have not paid yet, place a new order: each order has its own address.
- Card payment is not available yet.
What are free inotrack domains, and what's the catch?
If you have no domain of your own, the installer (--free-domains) or tracker-ctl domain claim issues a subdomain like abcd2345.lnkhop.online in one of the inotrack zones
(lnkhop.online, redirly.online, nxtclk.online, gohopr.site, clkpath.site) — for the
panel and for click intake. The license server sets the A record to the IP of the server the
request came from; TLS is issued by your own Caddy. Installation details are in the
install guide, "Free inotrack domains". What you need to know:
- The zones are shared by all inotrack customers. A zone's reputation with ad platforms and antivirus vendors is shared: if a platform blocks the whole zone, your subdomain is affected too. For serious traffic, set up your own domains (you can add them later and make them primary, see "How do I change the tracker domain if it gets blocked?").
- The record is not proxied through Cloudflare (proxied=false, TTL 300): traffic goes
straight to your server, and a public IPv4 address is required — behind NAT or with IPv6
only, the license server refuses (
ip_not_allowed). - The number of domains is limited by the plan (
limit_exceeded). A repeat request for the same purpose returns the same domain. - Moving to another server — repeat
tracker-ctl domain claimfrom the new server: the record moves to its IP, but no more than once an hour (ip_change_too_soon). - Domains live as long as the license does: for a license that was revoked or expired more than 30 days ago, they are deleted. Trial key → purchased key — see the question above.
What happens when the license expires?
The licensing protocol (key activation, heartbeat to the license server, a 72-hour offline
grace period by default, soft/hard modes) works on the CLI side as well —
tracker-ctl license info/refresh show the real status and the real grace deadline and
actually contact the license server.
Since 2026-09-24 the tracker binary that receives traffic checks the license too: the token
signature is verified with an embedded Ed25519 key, the state is cached on disk (tied to the
license key and the machine fingerprint, with protection against the system clock being
rolled back), and the ok/soft/hard mode is computed according to the licensing protocol
rules. Before that, the tracker had a stub that always answered "license is valid".
Since 2026-10-03 the release image has no permissive stub at all:
- Dev mode exists only in a developer build.
INOTRACK_LICENSE_DEV=1disables the check only in a binary built from source with thelicensedevtag (go build -tags licensedev) — for tests and local test setups. The release image is built without the tag: there the variable is ignored, anERRORis written to the log, and the license is checked as usual. - The client could not be created (the license server's public key is not embedded in the
image and
INOTRACK_LICENSE_PUBKEYis not set). The instance does not crash — the admin panel opens and the reason is visible in the log atERRORlevel — but the license is considered invalid:hardmode (see below for what that means for traffic). Previously this fell back to "everything allowed". - The public key is embedded in the image at build time: then
INOTRACK_LICENSE_PUBKEYfrom the environment is ignored, and you cannot substitute your own key together with your own license server (INOTRACK_LICENSE_URL) — a third-party server will not sign a token that passes verification.
How this works in practice — and where it does not work quite the way the protocol describes:
- soft (the license has expired but the offline grace period is not yet exhausted, or the
server itself sent
soft) closes all of/api/v1at once, not just the paid modules: the license check on every API request refuses any feature if the current mode is notok. Under/api/v1live not only the paid proxies (reports, rules, finance) but also ordinary offers/flows/sources/sites/webhooks, and the panel goes there too with its session cookie. In practice, in soft mode the entire management part of the product stops responding (403license_required), not just the billable modules; signing in to the panel itself (/api/internal/auth, a circuit separate from/api/v1) still works, but any action inside no longer does. - hard (the grace period is exhausted, or the server explicitly sent
hard) has the same effect as soft, plus/readyzstarts responding503. But click intake itself (/go/{token},/api/click) and postback intake (/pb/{token}) do not check the license mode at all — these paths do not go through/api/v1and do not ask for the license mode. Stopping traffic in hard mode works only if something outside actually watches/readyzand takes the instance out of load balancing: in the Kubernetes manifests this is wired up, but in the shippeddocker-compose.ymlit is not. Caddy (Caddyfile) proxies tracker domains totracker-core:8080unconditionally, without an upstream health check; the dockerhealthcheckof thetracker-corecontainer is a bare TCP connect to the admin port (tracker-ctl healthcheck-local), which does not ask/readyzeither. Conclusion: in a standard self-hosted install (docker compose), even an exhausted offline grace period does not stop click and postback intake — only/api/v1is shut off and/readyzis flagged, and nothing watches it in this setup.
Not gated by the license at all, regardless of mode: smartlink, landing, funnel,
finance, guard (the anti-bot gateway), MCP, multi-user access. The license defines 12
feature switches, but the tracker actually checks only four: public_api (described above,
checked on every request), rules_engine, push_service and rank_tracker — and the last
three are checked once at process start (they decide whether to mount the module at all),
not on every request: if the license degrades while the instance is already running, browser
notifications and the rank tracker section keep responding until a restart.
What telemetry does the instance send to the license server?
In short: counters, service activation identifiers, the domains of the installation itself and
the server's public address — not offers, not creatives, not the domains of your sites and
traffic sources, not amounts and not error texts. The complete, verbatim list of
POST /v1/heartbeat fields, sent every ~15 minutes by default (INOTRACK_LICENSE_HEARTBEAT_MIN, with ±20% jitter):
| Field | What it is |
|---|---|
license_token |
the current signed license token (it contains neither domains nor offers) |
instance_id |
activation id, issued by the server once at install time |
fingerprint |
sha256(the instance's machine-id + the sorted list of tracker domains) — an opaque string; why the domains cannot be recovered from it is explained below |
usage.clicks |
number of clicks for the period — a counter, not a list of clicks |
usage.conversions |
number of conversions for the period |
usage.users |
number of admin panel users |
usage.domains |
the number of connected tracker domains, not a list of them |
usage.clicks_24h |
number of clicks in the last 24 hours (excluding test ones) — a single number for the whole instance, with no breakdown by flows, domains or sources. It is computed in the background once an hour by a query to ClickHouse, and the heartbeat takes the ready value; the field is absent if ClickHouse is not connected or has not responded for more than three hours |
version |
the tracker build version (for example 1.4.2) |
domains |
the list of the installation's own domains: the panel domain, the tracker domains (including a free pool domain) and the edge node domains. Not the domains of your sites, landers or traffic sources. They are needed for licensing and protection against unlicensed use: they show where the license is running |
public_ip |
the server's public IP that the installer detected and wrote to .env (INOTRACK_PUBLIC_IP); the field is absent if the value is empty or none. To turn it off: INOTRACK_PUBLIC_IP=none at install time (see installation). The address the request itself came from is visible to the license server in any case |
The installation's domains and addresses are kept with a 12-month history; you can see yours in your account, and they are erased when the account is deleted.
That is the entire set — not a paraphrase but the exact schema, pinned by automated tests on both sides of the protocol (in the tracker and in the license server): they fail if anyone adds a new field to the report, even if it is not used right away.
The actual request body, captured on a test instance (values substituted for the example):
{"license_token":"<ILT>","instance_id":"ino_9f2c...","fingerprint":"82a2d405ab91bc9b414e2ba15e257324aa4793bebfb5297600bdfdbf78f153e3","usage":{"clicks":424242,"conversions":777,"users":3,"domains":5},"version":"1.4.2"}
Why fingerprint cannot be reversed by brute-forcing domains. It is not a hash of a
single domain with a fixed salt — such a scheme really could be cracked by brute force: the
number of domain names on the internet is finite and not that large, and a GPU tries billions
of sha256 hashes per second. The composition here is different:
sha256(machine-id + "|" + sorted_domains_comma_separated), where machine-id is 16 random
bytes (128 bits of entropy) generated by the instance itself on first install and stored only
on the customer's disk (/var/lib/inotrack/license/machine-id, mode 0600, root-only
permissions inside the container). This machine-id is never sent separately to the license
server or anywhere else — only the result already hashed together with the domains. To
recover the original domains from the fingerprint alone by brute force, an attacker would
have to brute-force both the domain and a 128-bit secret they do not know and cannot see in
any protocol message — this is computationally infeasible (unlike brute-forcing a single
domain with a known salt). The only thing fingerprint is good for is an equality comparison
with an already known specific value (for example, when investigating a specific incident, if
the domain is already known to the investigating party), not recovering the list of domains
from scratch.
Separate from the heartbeat telemetry is activation. POST /v1/activate (once when the
instance is installed, and again only if the license is forcibly reset in the admin panel or
moves to another machine) does send the tracker domains in plain text, in the domains field.
This is not telemetry but a separate, standalone mechanism for binding the license to the
place of installation (a domain lock — the same logic as most node-locked licenses of any
other software). After activation, the heartbeat reports the installation's domains in the domains
field (see the table above) — this is how the license server learns about a domain change
without a new activation; it carries no per-domain statistics, only their number
(usage.domains).
What exchange rate is used for money in different currencies, and what if the rate source is unavailable?
The rate is fixed on the date of the event, not at the moment you opened the report: for
a revenue entry it is occurred_at, for a spend entry — spent_on, for a report period — its
end. The same request with the same date must return the same amount today and in a month —
this is not a general wish but behavior explicitly verified by an automated test, because violating it
is exactly what makes a report for a past period non-reproducible. The rate history is stored
in fx_rates (never overwritten, only appended to) — you can always compute what a report
would have shown a week ago at the rates of that time.
₽/$/€ are equal (none of them is the "main storage currency" — amounts in finance_entries
are kept in the original currency of the event), but technically the ruble is used as the
settlement pivot: the Bank of Russia publishes rates only as "currency → ruble" pairs, so
there is nowhere to take a direct USD/EUR rate from other than computing it via the ruble.
The sources are the Bank of Russia (USD/EUR) and CoinGecko (USDT), synced once a day by a
background worker.
If fx_rates has any rate no later than the event date, the last one known before that
date is used, and this is not a degradation: the Bank of Russia does not publish rates on
weekends and holidays, and the accounting standard is the rate of the last business day
before the transaction. If there is no rate at all for this date or any earlier date
(fx_rates is empty, the sync has never run, or the date is earlier than the very first
stored one), the conversion fails with a loud error rather than silently computing 1:1 or
taking a rate from the future: lying with a number in the P&L/balance is worse than showing a
clear error.
The freshness of the source is visible at GET /api/v1/finance/fx/status (role admin): for
USD and EUR it returns the date of the last known rate, its age in hours and a stale flag
(today: older than 3 days in a row without an update, not counting ordinary weekends). This is
the only way to tell "the rate source has not been responding for days" apart from "it's
Saturday, there is no rate yet" — if stale=true, the Bank of Russia/CoinGecko sync is broken
and worth checking.
Who can access a report link if I share it?
The tracker has secret read-only report links (creation, a list of issued links, revocation,
a lifetime limit, hiding financial fields) — and via a link the report really is accessible to
people who have no account in the system. Link management
(POST /shares, GET /shares, POST /shares/{id}/revoke) still lives under the
authenticated /api/v1/reports/* and requires a Bearer token, like the rest of the reports
proxy. The viewing itself (GET /shares/view/{token}) is
open publicly under /shares without a Bearer token and without a session —
only by an unguessable token in the URL, exactly as intended. Rate limiting is applied per
link (not per request IP), so that a leaked link does not turn into free load on ClickHouse.
Revoking a link instantly closes access for everyone who has it — the share token is checked
against an active record in the database, and a revoked link gets 410 Gone.
Who else can sign in to the panel besides the first administrator?
The second (and third) user is added by the owner (role admin) — by invitation, the
standard way, without editing the database. The role model has three roles,
admin/buyer/viewer; a buyer sees only their own flows/sources, a
viewer is read-only.
# admin role only, the owner's session cookie (see the quickstart, appendix "the same path through the API", step 0)
curl -s -b cookies.txt -X POST "$TRACKER/api/internal/auth/users/invite" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","role":"buyer"}'
# the response contains the invitation token, returned ONCE (only its sha256 hash is stored in the DB).
# Link for the invitee: https://<panel>/invite?token=<token>, valid for 7 days.
The invitee opens the link, enters a name and password (for the complexity requirement, see
the operations guide, section "Environment variables") and immediately
gets a working session — POST /api/internal/auth/invite/accept, a public route: they do not
have an account yet, and therefore no session, so the invitation token is the only protection.
The owner manages the team with the same group of endpoints (all of them admin role only):
GET /api/internal/auth/users— list of team members;PATCH /api/internal/auth/users/{id}/role— change the role;POST /api/internal/auth/users/{id}/disable//enable— disable/restore access. Disabling forces a re-login immediately (revokes all sessions), rather than just forbidding the next/login;POST /api/internal/auth/users/{id}/2fa/reset— reset 2FA for another user (not yourself — see access recovery below).
You cannot demote or disable the last active administrator — the attempt returns
409 conflict; the product does not let the team get locked out without an owner.
The panel offers the same in the "Users and audit" section (/users): the list of team
members, invitations and the action log; the curl above is for scripts.
An administrator lost the phone with 2FA — how do I restore access?
Previously there was no standard way (only a direct UPDATE in Postgres). Today there are two
independent working paths, both verified by automated tests:
If the team has a second
admin. They sign in as usual (password + their own 2FA) and reset the affected user's 2FA:curl -s -b cookies-of-second-admin.txt -X POST \ "$TRACKER/api/internal/auth/users/<affected-user-id>/2fa/reset"This works without restarting the process. The victim's stolen password is useless here — it takes a live session of the second administrator, not anything the attacker knows.
If there is only one administrator (no time to add a second one, or this is the only team member in the system) — an emergency switch via an environment variable on the server:
# in the instance's .env (/opt/inotrack/.env), then restart the process [email protected]:incident-2026-09-24The label after
:is any non-empty string unique to the specific incident (a date or a ticket number is simplest): it is by this label (and not by the mere presence of the variable) that the product decides whether to apply the reset again. This protects against the most common mistake with such switches — forgetting to remove the variable after the incident: without the label, the next ordinary restart (update, container restart) would wipe 2FA once more, after the administrator had already set it up again. With the label, a repeat restart with the same value is a no-op; for a new incident you have to change the label deliberately.The reset runs synchronously at process start, before the traffic ports are opened — it shows up in the log at
ERRORlevel (the message is in Russian; find it by the variable nameINOTRACK_AUTH_RESET_2FAin the line — it tells you to sign in with your password immediately, set up 2FA again and remove the variable from the configuration) and as an entry in the audit log. Right after signing in, set up 2FA again (POST /2fa/setup→/2fa/confirm) and remove the variable from.env— it does not clear itself.This is exactly the mechanism that distinguishes "physical access to the server" (can edit
.envand restart the container) from "a stolen password/cookie" (no HTTP request triggers this path) — which is what was required: server access gives a way out, a stolen password does not.
In both cases, and without them, what does not change by design (a deliberate product
decision, not a defect): POST /2fa/disable unconditionally responds 403 for the admin
role regardless of the password or a recovery code — 2FA is mandatory for the owner and cannot
be removed by yourself with a password, only by one of the two paths above. For the
buyer/viewer roles self-service works as usual: POST /2fa/verify with a recovery code
lets you sign in, then POST /2fa/disable with the password removes 2FA, then
POST /2fa/setup registers a new device.
What are browser (push) notifications, and how do I enable them?
Its own Web Push service (RFC 8291/8030 delivery, RFC 8292 VAPID) — not a third-party provider
like OneSignal; keys and subscriptions live with you. It collects web push subscriptions from
visitors of network sites and landing pages, segments the base by site/vertical/geo, and sends
campaigns on a schedule or on offer triggers. A click on a notification is not separate
analytics in a vacuum: the campaign link is an ordinary tracking link (/go/{token}), the
redirect goes through the same click path and lands in the common reports like any other
traffic source; its own push_sends/CTR are a metric of how the campaign itself is opened,
not an alternative source of truth about traffic.
To enable it, set the INOTRACK_PUSH_ENC_KEY variable (an AES-256-GCM key for the VAPID
private key in the DB, see the operations guide). Without it the
sending worker does not start and GET /push/vapid-public-key does not return a key — this is
a supported mode (module disabled), not a configuration error; the core logs a clear reason
and keeps running. With the key, the VAPID public key is generated automatically on first
start.
In the panel — the "Push notifications" section (/push): campaigns, segmentation and manual
sending, a mobile apps tab. The module's HTTP endpoints (subscribe/unsubscribe from a site,
campaign CRUD, starting a send) are for sites and scripts.
Next
Installation — the install guide. Maintenance — the operations guide. API — the API guide.