This is the full developer documentation for DecTog
# DecTog documentation
> Short links that open your app, route new users through the stores and tell you which link brought each install. SDKs for Flutter and React Native, one dashboard, your own infrastructure if you want it.
Popular
* [Platform setup](/sdk/platform-setup/)
* [URL parameters](/links/url-parameters/)
* [API overview](/api/overview/)
* [Local quick start](/self-hosting/local-quick-start/)
* [Troubleshooting](/guides/troubleshooting/)
Flutter SDK
## Live in three steps
Add the Flutter SDK, initialize it with your API key and create a link. The first attributed install appears in the dashboard.
1. ### Install the SDK
```bash
flutter pub add flutter_dectog
```
[Installation](/sdk/installation/)
2. ### Initialize & listen
```dart
final dectog = FlutterDectog();
await dectog.initialize(DectogConfig(
apiKey: 'dtg_pub_your_sdk_key',
));
```
[Initialize and handle links](/sdk/initialize-and-handle-links/)
3. ### Create links with attribution
Create a link in the dashboard, over the REST API or from the app.
[Create links from the app](/sdk/create-links-from-the-app/)
### Watch it live
Clicks, installs and events arrive in the dashboard in real time.
[Dashboard overview](/dashboard/overview/)
## Browse the documentation
### Start here
Create an account, register your app, add the SDK and ship your first attributed install.
### Links & routing
What happens when someone taps a link: app, store, QR page, bots, and every dec\_\* parameter.
### Flutter SDK
Installation, platform setup, deferred deep links, install attribution, events and the full API reference.
### React Native SDK
The Expo module and its config plugin: the same links, attribution and events from React Native.
### Dashboard
Every screen and every number on it, defined exactly as the server computes it.
### REST API
Authentication, request signing, rate limits, links, installs, events and webhooks.
### Self-hosting
Run DecTog on your own Cloudflare account: deploy, configure, operate and keep your data.
### Guides
Troubleshooting, migrating from Firebase Dynamic Links or Branch, automation and AI agents.
### Contributing
Repository layout, tests, migration rules and the licenses of the server, the SDKs and these docs.
# Admin API
> The cookie-session routes the dashboard uses, listed for self-hosters who automate against their own deployment. Not a stable contract.
Not a stable contract
Everything under `/api/admin` and `/api/auth` exists to serve the dashboard. Routes, shapes and error strings change whenever the dashboard does, without notice. The stable, versioned surface is `/api/v1` ([API overview](/api/overview/)). Use this page when you automate your **own** deployment and accept that the next release may break your script.
## Session basics
[Section titled “Session basics”](#session-basics)
* Authentication is a cookie, `dectog_session`, set by login, signup and Google sign-in. It holds an HS256 JWT signed with `JWT_SECRET` that expires after 30 days. Flags: `HttpOnly`, `SameSite=Lax`, `Path=/`, `Secure` only when the request came over https. Login without `remember` sets a browser-session cookie (the token inside still lasts 30 days); `remember: true`, signup and Google sign-in set `Max-Age` of 30 days.
* Logout deletes the cookie; that token is not revoked server-side. The token carries the account’s session epoch (`ep`, `users.session_epoch`): a password reset and connecting Google raise it, which ends every session of the account made before. Rotating `JWT_SECRET` signs everyone out.
* Routes marked **session** answer `401` with code `not_authenticated` without a cookie and `401` with code `invalid_session` for a bad or expired token, a token from before the account’s last epoch, or a deleted user.
* Google: `GET /api/auth/google/start` signs in by the Google account’s id only (an existing account with the same email is not linked; the callback answers `/login?google_error=connect_from_settings`). With `?connect=1` and a session it connects Google to that account and returns to `/dashboard/settings?google=connected`, or `?google_error=` `google_in_use`, `other_google_account`, `cancelled`, `expired` or `failed`.
* Routes whose **Auth** is **owner** additionally require the platform `owner` role (`403 {"error": {"code": "forbidden", "message": "Owner access required"}}`), which is separate from the team roles under [Teams](#teams). The owner is the account created by bootstrap or the demo seed; signup and Google sign-in always create `user`, and there is no route to change roles. See [Account and sign-in](/dashboard/account-and-sign-in/).
* Every query is scoped to the account the dashboard shows: your own, one you joined (see [Teams](#teams)), or the test environment of either. There is no cross-account view.
* CORS on `/api/admin/*`: `access-control-allow-origin` is `WEB_URL`, with `access-control-allow-credentials: true`, `vary: Origin` and allowed headers `authorization, content-type, x-request-id`; `x-request-id` is exposed. `OPTIONS /api/*` answers 204.
* Errors use the same `{"error": {"code", "message", "request_id"}}` envelope as the v1 API ([Errors](/api/overview/#errors)). A body that is not valid JSON answers `400 {"error": {"code": "invalid_request", "message": "Invalid JSON body"}}`.
Log in and call a session route with curl
```bash
curl -sS -c jar.txt -X POST https://app.dectog.com/api/admin/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"…","remember":true}'
curl -sS -b jar.txt https://app.dectog.com/api/admin/links | jq '.links[] | {slug, click_count, installs}'
```
Self-hosted deployments replace `https://app.dectog.com` with their own `PUBLIC_BASE_URL`.
## Accounts and sign-in
[Section titled “Accounts and sign-in”](#accounts-and-sign-in)
| Method and path | Auth | Purpose | Notable responses |
| --------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `POST /api/admin/bootstrap` | `x-bootstrap-token` header when `ENVIRONMENT=production` or `BOOTSTRAP_TOKEN` is set | First account on an empty database. See below. | `201 {ok, email, username, api_key, demo}`; `403 Bootstrap not permitted`; `409 Already bootstrapped` |
| `POST /api/admin/signup` | public | `{email, password, username}`. Creates a `user` account and its first SDK key, sets a 30-day cookie, returns both keys. With `invite` (the token from an invite email) and no `username`: joins that account instead, as a sign-in with no account of its own and no keys; works while `OPEN_SIGNUPS=0`. The invite is checked before anything is written; the cookie `dectog_account` is set to the account joined. A `username` next to `invite` also creates your own account. | `201 {user, api_key, sdk_key}` (`api_key` is the account key); with an invite and no username `201 {user, joined: {account_id, account, role}}` (`user.username` is `null`); `403 Signup is currently closed` (`OPEN_SIGNUPS=0`, unless joining from an invite with no username); `400 Please enter a valid email address`, a password-rule message, `Username must be 3-32 lowercase letters, digits or hyphens, starting and ending alphanumeric`, `Username '' is reserved`; `409 An account with this email or username already exists`; for the invite: `403 invite_other_email` (another address), `404 invite_not_found`, `409 invite_used`, `410 invite_expired` |
| `POST /api/admin/login` | public | `{email, password, remember?}`. Email is trimmed and lower-cased. A test environment can never sign in. | `200 {user: {id, email, username, role, created_at}}` plus the cookie; `400 Email and password are required`; `401 Invalid email or password`; `429` over 10 attempts per minute from one IP. No per-account lockout. |
| `POST /api/admin/logout` | public | Deletes the cookie. | `200 {ok: true}` |
| `GET /api/admin/me` | session | The person signed in, and the account the dashboard shows: the live account, or its test environment after the switch below. Answers in no account at all too. | `200 {user, account, environment, test_environment}`: `user` and `account` are `{id, email, username, role, custom_domain, created_at, environment}`; `environment` is `live` or `test`; `test_environment` is `{username}` or `null`. `user.username` is `null` without an account of your own; in no account at all `account`, `environment`, `test_environment`, `role` and `live_account` are `null` and `accounts` is empty |
| `POST /api/admin/forgot-password` | public | `{email}`. Creates a one-hour, single-use reset token (older unused tokens are cancelled) and emails `/reset-password?token=…` through Resend, or logs the link when `RESEND_API_KEY` is unset. | `200 {ok: true, message: "If that email exists, a reset link is on its way."}` whether or not the account exists; `429` over 5 requests per minute from one IP or 3 per minute for one address. |
| `POST /api/admin/reset-password` | public | `{token, password}`. Does not sign the user in. | `200 {ok: true}`; `400 Reset token is required`, a password-rule message, `This reset link is invalid or has expired`, `This reset link has already been used` |
| `GET /api/auth/config` | public | What the login page shows. | `200 {signupOpen, googleEnabled, linkRootDomain}`; `linkRootDomain` is `ROOT_DOMAIN`, or `null` when unset |
| `GET /api/auth/google/start` | public | 302 to Google’s consent screen (authorization code with PKCE S256, `state`, scopes `openid email profile`, `prompt=select_account`). Sets `dectog_google_oauth` (10 minutes, path `/api/auth/google`). `?invite=` carries an invite through the flow. | `404 Google sign-in is not configured` when `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is unset; `400` for `connect` and `invite` together |
| `GET /api/auth/google/callback` | public | Google’s redirect target, `/api/auth/google/callback`. Signs in a linked account, links an existing account with the same verified email, or parks a new identity in `dectog_google_pending` (15 minutes). From an invite, a new identity whose email is the invited address joins that account with no account of its own (even with sign-ups closed), and a linked one is signed in and sent back to the invite. | 302 to `/dashboard`, `/login?google=choose_username`, or `/login?google_error=` with reason `not_configured`, `cancelled`, `expired`, `failed`, `other_google_account` or `signup_closed`. From an invite: `/dashboard`, `/invite?token=…`, or `/invite?token=…&google_error=` with an invite refusal code (`invite_other_email`, `invite_used`, `invite_expired`, `invite_not_found`), `connect_from_settings`, `other_google_account`, `cancelled`, `expired` or `failed` |
| `GET /api/auth/google/pending` | pending cookie | The parked identity for the username step. | `200 {email, name}`; `404 Google sign-in expired — try again` |
| `POST /api/auth/google/complete` | pending cookie | `{username}`. Creates the account with a random password (set a real one through forgot-password). | `201 {user: {id, email, username, role: "user"}, api_key}`; `401 Google sign-in expired — try again`; `403 Signup is currently closed`; `400` username rules; `409 That username is taken` or `An account with this Google email already exists — sign in instead` |
Password rule for signup and reset: 8 to 72 characters with at least one lowercase letter, one uppercase letter and one digit (`Password must be at least 8 characters`, `Password must be less than 72 characters`, `Password must include a lowercase letter`, `Password must include an uppercase letter`, `Password must include a number`). Username rule: `^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$` after lower-casing; reserved: `app`, `www`, `api`, `links`, `dashboard`, `admin`, `mail`, `static`, `cdn`, `docs`, `status`, `help`, `support`, and any name starting with `t-` (the prefix of test environments).
### Bootstrap
[Section titled “Bootstrap”](#bootstrap)
`POST /api/admin/bootstrap` has two body forms. Both answer `409 {"error": {"code": "conflict", "message": "Already bootstrapped"}}` once any user exists, and `201 {"ok": true, "email", "username", "api_key", "sdk_key", "demo"}` on success: `api_key` is the account key, `sdk_key` the first SDK key (`null` for the demo seed). The SDK key can be viewed again in Settings; the account key is shown only in this response.
| Body | Result |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `{"email", "password", "username"}` | Creates exactly that account with role `owner` and no demo data, in any environment. All three fields are required; password at least 12 characters (no other rule); username rules as above. Errors are `400` with `email, password and username are required`, `email is not a valid address`, `password must be at least 12 characters`, `username must be 3-32 lowercase letters, digits or hyphens, starting and ending alphanumeric`, `username '' is reserved`, or `409 Username or email already exists`. `demo` is `false`. |
| empty, not JSON, or a JSON object with none of those three keys | Seeds the demo account `demo@dectog.local` / `demo1234` / username `demo` (role `owner`) with the demo app, links, clicks and installs. `demo` is `true`. In production this answers `403 {"error": {"code": "forbidden", "message": "Demo seed is disabled in production — provide email, password and username"}}`. |
The header check runs first: when `ENVIRONMENT=production` or `BOOTSTRAP_TOKEN` is set, `x-bootstrap-token` must equal `BOOTSTRAP_TOKEN`, otherwise `403 {"error": {"code": "forbidden", "message": "Bootstrap not permitted"}}`. In production without a configured token every call is refused. See [Local quick start](/self-hosting/local-quick-start/) and [Deploy](/self-hosting/deploy/).
## Settings
[Section titled “Settings”](#settings)
| Method and path | Auth | Purpose | Response |
| ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/admin/settings` | session | Account and link configuration. | `200 {base_currency, retention_days, effective_retention_days, deployment_retention_days, email, username, key_prefix, role, link_mode, root_domain, public_base_url, example_link, custom_domain, default_link_host}`. The account key itself is never returned: it is shown once, by sign-up, bootstrap and regenerate. |
| `POST /api/admin/settings/regenerate-key` | session | Replaces the account key’s hash and prefix in one update. The old key stops working immediately; SDK keys are untouched. | `200 {api_key, key_prefix}`: the only time the new key is shown |
| `GET /api/admin/keys` | session | SDK keys, active first, newest first. | `200 {keys: [{id, name, kind, prefix, key, scopes, environment, last_used_at, created_at, revoked_at}]}`; `key` is the full key (`null` once revoked or when it cannot be decrypted) |
| `POST /api/admin/keys` | session | `{name?, links_write?}`. Creates an SDK key; `links_write: true` adds the `links:write` scope. | `201 {id, name, key, prefix, scopes}`; `409` at 20 active keys |
| `POST /api/admin/keys/:id/revoke` | session | Revokes an SDK key of the account at once. | `200 {ok: true}`; `404 Key not found` (unknown, foreign or already revoked) |
| `PUT /api/admin/settings/retention` | session | `{retention_days}`: the account’s own retention window, a whole number from 1 to 400, or `null` for the deployment’s `RETENTION_DAYS`. The shorter of the two applies, from the next daily run. | `200 {retention_days, effective_retention_days, deployment_retention_days}`; `400` otherwise. `GET /api/admin/settings` returns the same three fields. |
See [Dashboard: Settings](/dashboard/settings/).
## Teams
[Section titled “Teams”](#teams)
Every session route needs a team role in the account it acts on, and the role is checked before the handler runs: a role that falls short answers `403 {code: "insufficient_role", role, required_role}`, and a route with no entry in the contract table answers `403` whatever the role. Someone who joined from an invite has no account of their own; with no account left at all, only `GET /api/admin/me`, `PUT /api/admin/account`, `POST /api/admin/team/accept` and `POST /api/admin/accounts` run (role **none** below), and every other session route answers `403 {code: "no_account"}`. By default a viewer may read and a member may change; reads of keys, raw exports, customers, the audit log and webhook deliveries need a member, and keys, the team, the custom domain, the account’s settings, the test environment and erasures need an owner. `GET /api/admin/settings` returns the account key’s prefix, never the key, to every role. A viewer’s copy of `GET /api/admin/events`, `/installs/:id` and `/links` leaves out `customer_id`, `device_id`, event and install `metadata`, a tap’s `city`, `region`, `asn` and `as_org`, and a link’s `referrer_customer_id` (the install detail adds `personal_data_hidden: true`), and `GET /api/admin/webhooks` gives a viewer each `url` as its origin only.
| Method and path | Role | Purpose | Response |
| ---------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PUT /api/admin/account` | none | `{account_id}`: the account the dashboard shows (the `dectog_account` cookie, 30 days); also returns to its live data. Re-checked on every request: a membership since removed falls back to your own account, or, without one, to the first account you joined. | `200 {account_id, role}`; `404 not_a_member` (also for your own id when you have no account of your own) |
| `POST /api/admin/accounts` | none | `{username}`: opens your own account, for a sign-in made from an invite. Username rules as at signup. Creates the first SDK key, clears `dectog_account` and `dectog_env` so the dashboard shows the new account. | `201 {account: {id, username}, api_key, sdk_key}`, the keys shown only here; `400` username rules; `409 That username is taken`, or `conflict` when you already have one; `403 Signup is currently closed` |
| `GET /api/admin/team` | member | The account (always an owner) and its members; owners also get the pending invites. | `200 {account, members: [{user_id, email, username, role, since}], invites}`; `username` is `null` for a member with no account of their own |
| `POST /api/admin/team/invites` | owner | `{email, role}`: emails a link to `{WEB_URL}/invite?token=…`, valid 7 days, for that address only. | `201 {invite: {id, email, role, expires_at}}`; `409` for a member, the account itself, or 20 pending invites |
| `DELETE /api/admin/team/invites/:id` | owner | Revokes a pending invite. | `200 {revoked: true}` |
| `PUT /api/admin/team/members/:userId` | owner | `{role}` | `200 {user_id, role}`; `409` for the account itself |
| `DELETE /api/admin/team/members/:userId` | owner | Removes a member. | `200 {removed: true}`; `409` for the account itself |
| `POST /api/admin/team/leave` | any | Leaves the account shown. | `200 {left: true}`; `409` for your own account |
| `POST /api/admin/team/accept` | none | `{token}`: joins with an invite sent to your email. | `200 {account_id, account, role}`; `403 invite_other_email`; `404 invite_not_found`; `409 invite_used`; `410 invite_expired` |
| `GET /api/auth/invite?token=` | public | What the invite page shows before sign-in. `signed_up` says whether the invited address already signs in (sign-up’s `409` tells anyone as much), so the page leads with Sign in or with joining. | `200 {account, email, role, expired, accepted, signed_up}`; `404` |
`GET /api/admin/me` also returns `role` (yours in the account shown), `live_account` (`{id, username}`, the account selected even while its test environment is shown) and `accounts` (`[{id, username, role}]`, your own first if you have one).
## Test environment
[Section titled “Test environment”](#test-environment)
An account’s test environment is a second account, `t-`, with the same apps and link templates; see [Settings: Test environment](/dashboard/settings/#test-environment).
| Method and path | Auth | Purpose | Response |
| ---------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/admin/test-environment` | session (live) | Creates the test environment: the apps and link templates are copied, and it gets an account key and an SDK key whose `environment` is `test`. It has no password and an unroutable email, so it is reached only from its live account. | `201 {username, apps, templates, sdk_key, sdk_key_prefix}`; `409 test_environment_exists` the second time, `is_test_environment` from the test environment itself |
| `PUT /api/admin/environment` | session | `{environment: "live" \| "test"}`: which account the dashboard shows from now on, by the `dectog_env` cookie (30 days). Every other session route then acts on that account; the audit log records the person. | `200 {environment, username?}`; `404 no_test_environment` |
In the test environment, `POST /api/admin/custom-domain` answers `409 is_test_environment`: its links stay on `t-.`. Keys created there (SDK and site keys) are test keys. `GET /api/v1/sdk-config` answers `environment: "test"` for them.
## Personal data
[Section titled “Personal data”](#personal-data)
A request from one person, for a device or a customer; see [A request from one person](/self-hosting/privacy-and-data/#a-request-from-one-person) for what each covers. The same four routes exist under `/api/v1/devices/…` and `/api/v1/customers/…` for the account key (an SDK key gets `403 account_key_required`).
| Method and path | Auth | Purpose | Response |
| --------------------------------------------- | ------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/admin/devices/:deviceId/export` | session | Everything stored about one device. | `200`, a JSON file: `{subject, account, exported_at, retention_days, installs, clicks, events, app_opens, device_days, app_sessions, advertising_ids, referred_by}` |
| `DELETE /api/admin/devices/:deviceId` | session | Erases the device. | `200 {erased: true, device_id, deleted: {events, app_opens, device_days, app_sessions, ad_identifiers, referrals, customers_unlinked, webhook_deliveries, clicks_blanked, installs}}` (rows per table; `clicks_blanked` were kept without their personal columns) |
| `GET /api/admin/customers/:customerId/export` | session | Everything stored about one customer. | `200`, a JSON file: `{subject, account, exported_at, retention_days, customer, events, referred_by, referrals_made, devices: [{device_id, shared, …the device export when not shared}]}` |
| `DELETE /api/admin/customers/:customerId` | session | Erases the customer, and each device only they used. | `200 {erased: true, customer_id, deleted: {customer, events, app_sessions, referrals, webhook_deliveries, devices, shared_devices_kept, …the device counts}}` |
Erasing something that was never stored answers `200` with zero counts: the request is still recorded, and the tombstone still drops queued events.
## Audit log
[Section titled “Audit log”](#audit-log)
| Method and path | Auth | Purpose | Response |
| ----------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `GET /api/admin/audit` | session | The account’s audit log. `?limit=` 1 to 500 (default 50). Newest first, `?before_seq=` pages back; or `?after_seq=` reads forward, oldest first, to copy the log elsewhere. | `200 {data: [entry], next_before_seq}`, or `next_after_seq` with `after_seq`; `null` on the last page |
| `GET /api/admin/audit/verify` | session | Checks that the entries run 1, 2, 3… with none missing. | `200 {ok, count, first_seq, last_seq, gaps: [[from, to]]}` |
An entry is `{seq, action, actor, target, changes, ip_hash, user_agent, request_id, created_at}`:
* `actor` is `{type: "user", id, email}`, `{type: "account_key", prefix}`, `{type: "sdk_key", id, prefix, name}`, `{type: "system"}` (DecTog itself) or `{type: "anonymous"}` (a wrong password, a reset request).
* `target` is `{type, id?, name?}`: a link by slug, an app, webhook or key by id with its name, a webhook by its URL’s origin.
* `changes` is `{field: [before, after]}`, or `null`. Secret fields hold `"[redacted]"`.
* `request_id` matches the `x-request-id` of the response that made the change.
| Area | Actions |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Links | `link.created`, `link.updated`, `link.deleted`, `link.restored`, `link.bulk_created` (with the slugs), `temp_link.created`, `temp_link.updated`, `temp_link.deleted`, `temp_link.restored`, `temp_link.expired_deleted`, `template.created`, `template.updated`, `template.deleted` |
| Apps | `app.created`, `app.updated`, `app.deleted`, `app.store_notifications_enabled`, `app.store_notifications_rotated`, `app.store_notifications_updated`, `app.store_notifications_disabled` |
| Webhooks | `webhook.created`, `webhook.updated`, `webhook.deleted`, `webhook.secret_rotated`, `webhook.tested`, `webhook.delivery_resent`, `webhook.disabled` (by DecTog) |
| Custom domain | `custom_domain.added`, `custom_domain.removed`, `custom_domain.restored`, `custom_domain.status_changed` and `custom_domain.deleted` (by DecTog) |
| Test environment | `test_environment.created` (in the live account’s log; changes made in the test environment go to its own log) |
| Team | `team.invited`, `team.invite_revoked`, `team.joined` (by the person who joined), `team.role_changed`, `team.member_removed`, `team.member_left` |
| Keys and settings | `sdk_key.created`, `sdk_key.revoked`, `site_key.created`, `site_key.updated`, `site_key.revoked`, `account_key.regenerated`, `settings.base_currency_changed`, `settings.retention_changed` |
| Sign-in | `account.created`, `account.bootstrapped`, `auth.login`, `auth.google_login`, `auth.google_linked`, `auth.login_failed`, `auth.logout`, `auth.password_reset_requested`, `auth.password_reset` |
| Data | `data.exported` (the file name and row count), `data.subject_exported`, `data.device_erased`, `data.customer_erased` (the target a `sha256:` prefix, never the id; the changes the rows per table) |
| Blog (owner) | `blog.post_created`, `blog.post_updated`, `blog.post_deleted`, `blog.category_created`, `blog.category_updated`, `blog.category_deleted`, `blog.message_deleted` |
The SDK’s data plane writes none: installs, clicks, events, opens, pings, web events, store notifications, and the temporary and referral links apps create.
## Links
[Section titled “Links”](#links)
| Method and path | Auth | Purpose | Response |
| ------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/admin/links` | session | Every link except referral links, newest first, no pagination; deleted links only with `?include_deleted=1`. `?type=referral` lists only the referral links instead. | `200 {links: [...], temp_links: [...], referral_links: {active, archived, deleted}}`, the last counting the referral links per view; with `?type=referral`, `200 {links: [...]}`, each link adding `referrals` (the referrals it recorded). Each permanent link has every column (the lifecycle ones included), `metadata` parsed, plus `url`, `clicks_7d` (rolling 7 days), and lifetime `installs` (matched, non-reinstall), `opens`, `leads` and `revenue` (every currency summed, 2 decimals). Each temporary link has every column plus `url` and `is_expired`. |
| `POST /api/admin/links` | session | Same fields and rules as the v1 create, with the slug trimmed. | `201 {id, slug, target, url, click_count: 0, created_at, metadata}` (a subset of the v1 Link); `400 Invalid or missing slug`, `Invalid target: must be a valid http(s) URL`, `Unknown app_id`, the `og_*` and routing messages; `409 Slug already exists` with `slug` |
| `PUT /api/admin/links/:slug` | session | Same partial-update semantics as v1 (absent keeps, `null` or `""` clears), lifecycle fields included. | `200 {ok: true, link: }`: `metadata` is still a JSON string and there is no `url`; `404 Link not found` |
| `DELETE /api/admin/links/:slug` | session | Soft delete, like v1; `?permanent=1` deletes the link and its clicks, attributed installs, events and app opens. | Always `200 {deleted: true}`, even for an unknown slug |
| `POST /api/admin/links/:slug/restore` | session | Restores a deleted link. | `200 {ok: true}`; `404 Link not found` |
| `GET /api/admin/links/:slug/screens` | session | The screens reached in the 30 minutes after the link opened the app, from the app sessions that carry it: a re-open through it (once per device and open time, timed on the device from `open_at`) or the first launch of an install credited to it (its sessions first flushed within 30 minutes of the registration, timed from their first screen), over `?days=` (1 to 365, default 30). Permanent or temporary link. | `200 {slug, days, window_minutes: 30, opens, screens: [{name, opens, first}]}`: `opens` counts the opens that reported a screen; per screen, the opens that reached it and those that started on it, at most 20; `400` for a bad `days`; `404 Link not found` |
| `POST /api/admin/temp-links` | session | Same fields and TTL rules as v1 (`ttlSeconds` default 900, `0` becomes 900, clamped to 60 to 2,592,000; auto slug `t_<10 hex>`). | `201 {id, slug, target, url, ttl_seconds, expires_at, created_at, og_title, og_description, og_image_url}`; `400 ttlSeconds must be a number`, `Invalid slug`; `409 Slug already exists` |
| `PUT /api/admin/temp-links/:slug` | session | `target`, `og_*` and the lifecycle fields (no `expires_at`). | `200 {ok: true}`; `404 Temporary link not found` |
| `DELETE /api/admin/temp-links/expired` | session | Deletes every temporary link of the account whose `expires_at` has passed. Their clicks and installs are kept. | `200 {deleted: }` |
| `DELETE /api/admin/temp-links/:slug` | session | Soft delete; `?permanent=1` deletes with history, like permanent links. | Always `200 {deleted: true}` |
| `POST /api/admin/temp-links/:slug/restore` | session | Restores a deleted temporary link. | `200 {ok: true}`; `404 Temporary link not found` |
## Webhooks
[Section titled “Webhooks”](#webhooks)
The routes behind Apps → Webhooks; see [Webhooks](/api/webhooks/) for the envelope, the events and retries.
| Method and path | Auth | Purpose | Response |
| ----------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/admin/webhooks` | session | Every webhook of the account. | `200 {data: [Webhook], events, default_events}`; a Webhook is `{id, app_id, url, events, active, consecutive_failures, disabled_at, disabled_reason, created_at, updated_at}`, never its secret |
| `POST /api/admin/webhooks` | session | `{url, app_id?, events?}`; `app_id` null or absent takes every app’s events; `events` defaults to all but `click.recorded`. | `201` Webhook plus `secret` (`whsec_` and 64 hex), the only time it is shown; `400 url must be a valid http(s) URL`, `Unknown event …`, `Unknown app_id` |
| `PUT /api/admin/webhooks/:id` | session | Any of `url`, `app_id`, `events`, `active` (boolean); `true` resets the failure count. | `200` Webhook; `404 Webhook not found` |
| `DELETE /api/admin/webhooks/:id` | session | Deletes the webhook and its delivery log. | `200 {deleted: true}`; `404` |
| `POST /api/admin/webhooks/:id/rotate-secret` | session | A new secret; the old one stops working at once. | `200` Webhook plus `secret` |
| `POST /api/admin/webhooks/:id/test` | session | Delivers `webhook.test` now; never retried, never counted. | `200` the logged Delivery |
| `GET /api/admin/webhooks/:id/deliveries?limit=` | session | The latest deliveries, newest first, `limit` 1 to 200 (default 50). | `200 {data: [Delivery]}`; a Delivery is `{id, webhook_id, event_id, event, data, attempt, status, http_status, response_excerpt, error, next_attempt_at, delivered_at, created_at, updated_at}` |
| `POST /api/admin/webhook-deliveries/:id/resend` | session | One more attempt now, with the same `event_id`. | `200` the Delivery after it |
## Apps
[Section titled “Apps”](#apps)
| Method and path | Auth | Purpose | Response |
| ------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/admin/apps` | session | All apps, oldest first. The first one is the account’s default app. | `200` bare array with every column; `android_sha256_fingerprints` parsed into an array. |
| `POST /api/admin/apps` | session | Create. `name` is the only required field. | `201 {id, name}`; `400 App name is required` or a field message |
| `PUT /api/admin/apps/:id` | session | Partial update: absent keeps, a blank `name` keeps the old name, `""` or `null` clears other fields. | `200 {ok: true}`; `404 App not found` |
| `GET /api/admin/apps/:id/setup` | session | The setup checklist for one app, computed, never stored. | `200 {app_id, done, total, steps: [{key, label, status: done\|todo\|skipped, detail}], rules: {aasa, assetlinks}}`; each `rules` entry is null for a platform not set up, else `[{id, ok, message}]`; `404 App not found` |
| `DELETE /api/admin/apps/:id` | session | Deletes the app; links bound to it get `app_id: null` and follow the default app from then on. Installs and clicks are untouched. | `200 {deleted: true}`; `404 App not found` |
| `GET /api/admin/site-keys` | session | Site keys for [`dectog.js`](/sdk/web/). | `200 {keys: [{id, name, prefix, key, origins, last_used_at, created_at, revoked_at}]}` |
| `POST /api/admin/site-keys` | session | `{name?, origins: [1 to 20 http(s) origins]}`; at most 10 active. | `201 {id, name, key, prefix, origins}`; `400`; `409` |
| `PUT /api/admin/site-keys/:id` | session | `{origins}` replaces the list. | `200` the key; `404` |
| `GET /api/admin/custom-domain` | session | The account’s custom link domain. | `200 {configured, target, domain: null \| {hostname, status, suspended, delete_after, cloudflare: {hostname_status, certificate_status}, dns_records: [{type, name, value, purpose}], errors, last_checked_at, activated_at, created_at, url_example}}` |
| `POST /api/admin/custom-domain` | session | `{hostname}`: a subdomain of at least three labels, not of this deployment. Creates the Cloudflare custom hostname with TXT validation. See [Custom domains](/links/custom-domains/). | `201` as above; `400 invalid_hostname`; `409 domain_exists`, `hostname_taken`, `not_configured`; `502 cloudflare_error` |
| `POST /api/admin/custom-domain/refresh` | session | Asks Cloudflare (and DNS) now. | `200` as above; `404` without a domain |
| `DELETE /api/admin/custom-domain` | session | Suspends a domain that served (served 7 more days, then deleted); deletes one that never did. | `200` as above |
| `POST /api/admin/custom-domain/restore` | session | Undoes a removal within the 7 days. | `200` as above; `404` when nothing is removed |
| `GET /api/admin/links/:slug/preview-check` | session | What Facebook, X, LinkedIn, Slack, Discord, WhatsApp, iMessage and Telegram show for one of your links (permanent or temporary): each crawler’s user agent through the redirect engine (no click recorded), the image fetched as each. See [Social previews and QR](/links/social-previews-and-qr/#check-the-preview-on-each-platform). | `200 {url, checked_at, expected: {title, description, image, card}, platforms: [{id, name, result: ok \| not_crawler \| blocked \| error, status, location, seen, image: {url, result, status, content_type, bytes}, issues}]}`; `404 Link not found` |
| `GET /api/admin/og-preview?url=` | session | Fetches a public page server-side and extracts a social preview. Follows redirects, 8-second timeout, reads at most 512 KB, first tag wins (`og:title`, `twitter:title`, ``; `og:description`, `twitter:description`, `description`; `og:image:secure_url`, `og:image`, `twitter:image`, `twitter:image:src`), title and description cut to 150 characters. | `200 {title, description, image}`; `400 url query parameter is required`, `Enter a valid http(s) URL`, `Enter a valid public http(s) URL` (non-http scheme or a loopback, private or link-local host); `502 Couldn't reach that URL`, `That URL returned HTTP `; `422 That URL isn't an HTML page`, `No title, description or image found on that page` |
App fields and their validation messages (`400`). Every text field is trimmed before it is validated and stored, on create and on update:
| Field | Rule |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ios_bundle_id`, `android_package` | `com.company.app` shape: `ios_bundle_id must look like com.company.app`, `android_package must look like com.company.app` |
| `ios_team_id` | 10 uppercase letters or digits: `ios_team_id must be a 10-character Apple Team ID (e.g. 8H7C9JL2K3)` |
| `ios_store_url`, `android_store_url` | Host exactly `apps.apple.com` / `play.google.com`: `ios_store_url must be an apps.apple.com URL`, `android_store_url must be a play.google.com URL` |
| `huawei_store_url`, `samsung_store_url`, `xiaomi_store_url` | Host on the store’s domains (Huawei `appgallery.huawei.com`, `appgallery.cloud.huawei.com`, `url.cloud.huawei.com`; Samsung `galaxystore.samsung.com`, `apps.samsung.com`, `galaxy.store`; Xiaomi `global.app.mi.com`, `app.mi.com`, subdomains included): `_store_url must be a valid http(s) URL`, `_store_url must be a URL` |
| `vivo_store_url` | Any http(s) URL |
| `android_sha256_fingerprints` | Array; each upper-cased and matching `AA:BB:…` (32 pairs); blanks dropped: `SHA-256 fingerprints must be 32 hex byte pairs separated by colons (AA:BB:…)` |
| `fallback_url` | http(s): `fallback_url must be a valid http(s) URL` |
| `ios_app_scheme`, `android_app_scheme` | A trailing `://` is stripped; letters, digits, `+`, `-`, `.`; `http`, `https`, `ws`, `wss`, `javascript`, `file`, `data`, `blob`, `about` and `intent` are refused: `ios_app_scheme must be a URL scheme (letters, digits, +, -, .) without :// — e.g. myapp`, `ios_app_scheme must be a custom app scheme — the browser handles http/https/javascript itself, so it can never open the app` (same for Android) |
| `mobile_interstitial`, `strict_fraud_mode`, `ios_tap_to_store` | Truthy stores 1, else 0 |
| `mmp_handoff` | `true` or `false` (anything else is a 400): add `clickid` (and `pid`) to AppsFlyer, Singular and Adjust tracking-link destinations |
| `mmp_pid` | Optional media source for the handoff: 1 to 100 letters, digits, `.`, `-`, `_` |
| `low_entropy_window_hours` | Whole number 1 to 720, or `null` / `""` for the deployment default: `low_entropy_window_hours must be a whole number of hours between 1 and 720` |
| `og_title`, `og_description`, `og_image_url` | 150 characters, 150 characters, http(s) URL |
| `fb_app_id` | Free text, trimmed. Served to the SDK by `GET /api/v1/sdk-config`; the SDK reads Meta’s install referrer for this id unless `DectogConfig.fbAppId` in the app sets one. |
## Analytics
[Section titled “Analytics”](#analytics)
| Method and path | Auth | Purpose | Response |
| ---------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/admin/overview?days=` | session | The Overview screen. `days` defaults to 90, clamped to 1 to 365; the previous window of the same length feeds every `delta`. | `200 {window, kpis: {clicks, visitors, installs, conversion, revenue, leads, opens}, timeseries, campaigns, sources, referrers, devices, countries, device_models, carriers, connectivity, reinstalls, match_sources, fraud: {flagged, reasons}, apple_search_ads: {attributed, no_attribution, pending, unresolved, campaigns}}`. Definitions are in [Dashboard: Overview](/dashboard/overview/). |
| `GET /api/admin/events?limit=&type=` | session | Latest events, newest first. `limit` default 50, 1 to 200, no offset. `type` filters only when it is `identify`, `lead`, `conversion` or `custom`; other values return every type. | `200 {events: [...]}` with every column including `ip_raw`, `revenue` rounded to 2 decimals, `metadata` parsed |
| `GET /api/admin/customers?q=&limit=&offset=` | session | Customer list. `q` (trimmed, 200 characters) is a substring match on `customer_id`, `name`, `email`, `phone`; `limit` default 50, 1 to 200. | `200 {total, limit, offset, customers: [...]}`, each with the customer fields, `matched_slug`, `match_source`, `app_platform`, `os`, `device_model`, `country`, `city`, `events`, `revenue`, `last_event_at`, `shared_device_customers` |
| `GET /api/admin/customers/:customerId` | session | Everything known about one customer. | `200 {customer, stats, devices, install, click, link, events (200), opens (50), screen_sessions (20), shared_device_customers (50)}`; `404 Customer not found`. `install` and `click` omit `ip_hash` but keep `ip_raw`. |
| `GET /api/admin/retention?days=&slug=&campaign=&platform=` | session | The Retention card: day 1, 7 and 30 retention and weekly cohorts, calendar days in UTC. `days` default 90, 7 to 365; `slug` limits to installs credited to that link, `campaign` to installs whose click carried that `utm_campaign` or link campaign, `platform` to `ios` or `android` (anything else is a 400). | `200 {window: {days, start}, bucket: "calendar_day_utc", filters, installs, untracked, d1, d7, d30, weekly}`; each `dN` is `{eligible, retained, rate}` (`rate` a percentage with one decimal, null when nothing is eligible); `weekly` is `[{week, installs, retained: [n…], rates: [%…]}]`, newest first, nine weeks, null for weeks not started |
| `GET /api/admin/attribution-quality?days=` | session | The match-confidence and referrer-health panel. `days` default 7, 1 to 365. Reinstalls and updates are included. `signal_check` totals the agree, mismatch and missing verdicts per signal, scored and unscored, over the window’s installs that carry a `signal_check` (installs a deterministic signal matched to a recorded click); see [Attribution internals](/self-hosting/attribution-internals/#the-stored-explanation). | `200 {days, by_platform, referrer_status, match_sources, shadow: {n, agree, disagree}, signal_check: {n, signals: {: {agree, mismatch, missing}}}, latency: {p50_ms, p90_ms, n}, updates_skipped}` |
| `GET /api/admin/analytics?event=&group_by=&interval=&tz=&=` | session | The Breakdown card and the MCP `analytics` tool: see below. | `200 {window: {interval, tz, start, end, granularity, partial}, event, group_by, filters, truncated, data}`; `400` for any parameter it does not take |
| `GET /api/admin/export.csv?data=&…` | session | The analytics answer (`data=analytics`, the default) or the raw rows of `clicks`, `installs`, `opens` or `events` as CSV, with the same window and filters. | `200 text/csv`; past 50,000 rows `202` with an export job, or `422 export_too_large` without an `EXPORTS` bucket. See [Export raw data](/api/export/#csv). |
| `GET /api/admin/exports/:id` | session | A large export’s job. | `200 {id, data, status, total_rows, row_count, bytes, error, created_at, finished_at, expires_at, status_url, download_url}`; `404` for another account’s |
| `GET /api/admin/exports/:id/download?exp=&sig=` | signed link | The finished file. | `200 text/csv`; `403 invalid_download_link`; `404` once it is gone |
### The analytics query
[Section titled “The analytics query”](#the-analytics-query)
One query answers “how many, by what, where”: any of the metrics over a window, by time or by one or two dimensions, with filters.
| Parameter | Default | Values |
| -------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event` | the first five | One or more of `clicks`, `installs`, `opens`, `leads`, `revenue` and `ltv`, comma separated. `installs` are first installs; `revenue` is net of refunds, in the account’s currency, by event time. `ltv` (only when named) is the cohort view: the window’s first installs, each with its net revenue to date, divided by their number, so a source’s LTV keeps growing after the window; by time it is by install day. LTV counts the app’s revenue only: a purchase on the website ([`dectog.js`](/sdk/web/)) has no install to belong to. |
| `group_by` | `timeseries` | `timeseries`; one dimension; two (`campaign,country`, or `timeseries,device` for a series per platform); or `none` for one row of totals |
| `interval` | `30d` | `24h` (hourly: the current hour and the 23 before it), `7d`, `30d`, `90d`, `365d` (whole days, today included) |
| `start`, `end` | none | Local days `YYYY-MM-DD`, both included, in place of `interval`; at most 366 days |
| `tz` | `UTC` | An IANA time zone; days and hours are cut in it, changes of summer time included |
| `limit` | 100 | Rows (or, by time and a dimension, values of the dimension), 1 to 1000; past it `truncated` is true |
| a dimension | none | A filter: `country=US,DE` keeps those values, `country=-US` leaves them out. One dimension cannot do both. |
The dimensions are `link`, `campaign`, `adgroup`, `creative` (the link’s tracking fields or the URL’s `dec_*` values), `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `country`, `region`, `city`, `browser`, `referrer`, `device` (`ios`, `android`, `desktop`, `other`), `os`, `os_version`, `lang`, `app_version`, `match_source`, `routing_rule` (the rule that routed the tap: a geo, language, schedule or A/B variant), `cta` (the button a visitor took on a link page: `cta_ios`, `cta_android`, `cta_open`, `cta_web`) and `event_source` (events only: `app`, `web` for [`dectog.js`](/sdk/web/), `store`).
An install takes the dimensions of the tap that won it; an event those of the link it counts for (the link that last opened the app within its re-engagement window, else the install’s), and an open those of its tap. What no link brought is `(organic)`; a link that carried no value for a dimension is `(none)`. Both are values like any other, so `country=-US` keeps the rows without a country and `utm_source=(none)` finds the taps without one.
Each row has the group values, then the metrics; `conversion` (installs per click, in percent, one decimal) joins them when both are asked for. A metric whose rows lack the dimension, such as clicks by `match_source` or `app_version`, is `null` rather than 0. By time, every day or hour of the window is there, zero when nothing happened. `window.partial` is true when the window starts before the raw rows the retention window keeps.
Installs and revenue by campaign and country, last 30 days, Berlin time, without the US
```bash
curl -sS -b jar.txt 'https://app.dectog.com/api/admin/analytics?event=installs,revenue&group_by=campaign,country&interval=30d&tz=Europe/Berlin&country=-US'
```
## Blog, messages and contact
[Section titled “Blog, messages and contact”](#blog-messages-and-contact)
Owner-only CMS routes under `/api/admin/blog` and the public site routes under `/api`. See [Blog and messages](/dashboard/blog-and-messages/).
| Method and path | Auth | Purpose | Response |
| ------------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/admin/blog/posts?limit=&offset=&search=` | owner | Posts including drafts, by `updated_at` descending. `search` matches title or slug; `limit` default 50, max 200. | `200 {posts, total, limit, offset}` |
| `GET /api/admin/blog/posts/:id` | owner | One post. | `200 {post}`; `404 Post not found` |
| `POST /api/admin/blog/posts` | owner | `{title (1-200), slug? (derived from the title), excerpt (≤500, required), content (HTML, required), content_blocks?, featured_image_url?, category_id?, published?}`. | `201 {post}`; `400 title, slug, excerpt and content are required`, `Title must be 1-200 characters`, `Slug may only contain letters, numbers, "-" and "_"`, `Excerpt must be at most 500 characters`, `Content is required`, `Featured image must be a valid http(s) URL`, `Unknown category`; `409 A post with this slug already exists` |
| `PUT /api/admin/blog/posts/:id` | owner | Partial update; the first publish stamps `published_at`, unpublishing keeps it. | `200 {post}`; `404`; `409` |
| `DELETE /api/admin/blog/posts/:id` | owner | | `200 {ok: true}`; `404 Post not found` |
| `GET /api/admin/blog/categories` | owner | Every category with `post_count` (drafts included). | `200 {categories}` |
| `POST /api/admin/blog/categories` | owner | `{name (1-80), slug?}`. | `201 {category}`; `400 Name must be 1-80 characters`; `409 A category with this slug already exists` |
| `PUT /api/admin/blog/categories/:id` | owner | `{name?, slug?}`. | `200 {category}`; `400 Nothing to update`; `404 Category not found`; `409` |
| `DELETE /api/admin/blog/categories/:id` | owner | Posts in it become uncategorized. | Always `200 {ok: true}` |
| `GET /api/admin/blog/messages?limit=&offset=` | owner | Contact-form inbox, newest first; `limit` default 50, max 200. | `200 {messages, total, limit, offset}` |
| `DELETE /api/admin/blog/messages/:id` | owner | | `200 {ok: true}`; `404 Message not found` |
| `GET /api/blog/posts?search=&category=&limit=&offset=` | public | Published posts, newest first; `limit` default 12, max 50; `search` matches title or excerpt; `category` is a category slug. | `200 {posts, total, limit, offset}` |
| `GET /api/blog/posts/:slug` | public | A published post. | `200 {post}`; `404 Post not found` |
| `GET /api/blog/categories` | public | Categories with at least one published post. | `200 {categories}` |
| `POST /api/contact` | public | `{name (1-100), email (valid, ≤200), subject (1-150), message (10-2000)}`. Stored only; no email is sent. | `201 {ok: true}`; `400 Name must be 1-100 characters`, `A valid email is required`, `Subject must be 1-150 characters`, `Message must be 10-2000 characters`; `429 Too many messages — try again later` |
## Rate limits
[Section titled “Rate limits”](#rate-limits)
| Route | Limit |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/contact` | 5 per hour per hashed IP, counted before validation, so invalid submissions use up the allowance. The 429 carries no `Retry-After`. |
| `POST /api/admin/login` | 10 per minute per hashed IP, counted before the password check. `429` with `Retry-After`. There is no per-account lockout, so nobody can lock a user out by failing on purpose. |
| `POST /api/admin/forgot-password` | 5 per minute per hashed IP and 3 per minute per email address. `429` with `Retry-After`. |
| Every other `/api/admin` and `/api/auth` route | **None.** Signup, reset-password and bootstrap are not rate limited. Put your own protection in front of them when you expose a self-hosted dashboard publicly. |
# Clicks and installs
> The SDK-facing routes that record in-app clicks, report first launches and late attribution claims, and the install verdict they return.
These are the routes behind install attribution. The Flutter SDK calls them for you (see [Install attribution](/sdk/install-attribution/)); this page is the contract for anyone porting the SDK or reading the responses. All three need the Bearer key and request signature from [API overview](/api/overview/).
The user-level explanation of the matching ladder is in [How attribution works](/start/how-attribution-works/); the scoring internals are in [Attribution internals](/self-hosting/attribution-internals/).
## POST /api/v1/clicks
[Section titled “POST /api/v1/clicks”](#post-apiv1clicks)
Records a click that happened inside the app: the app was already installed and a link opened it. Only `slug` is required.
| Field | Type | Required | Meaning |
| --------------------------------------------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `slug` | string | yes | One of your permanent or temporary slugs. Unknown slugs answer `404 {"error": {"code": "not_found", "message": "Link not found"}}`. |
| `click_id` | string | no | The `dtg_click` id the app received through an Android intent bounce. See the flip rules below. |
| `platform` | string | no | `ios` or `android` (any case). Sets the click’s device type and the OS label (`iOS 17.5`, `Android 14`). |
| `os_version` | string | no | Up to 32 characters; combined with `platform` into the OS label and the OS major. |
| `device_type` | string | no | Used only when `platform` is not `ios` or `android`; otherwise the server classifies the request’s User-Agent. |
| `is_mobile_app` | boolean | no | Truthy stores `source: "mobile_app"`; anything else stores `source: "web"`. |
| `session_id` | string | no | Stored as sent. |
| `language` | string | no | Up to 128 characters, stored as the click’s accept-language; its primary tag (`de` from `de-DE`) becomes `lang`. |
| `device_id` | string | no | Stored as sent; also keys the per-device rate limit. |
| `device_model` | string | no | Trimmed, unquoted and lowercased. |
| `metadata` | JSON | no | Stored when truthy. |
| `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | string | no | Stored as strings. |
| `link_params` | object | no | String-to-string map of the opened URL’s `dec_*` parameters. Parsed like the redirect engine does: `dec_deeplink` is checked against the link’s app schemes, tracking values are capped at 255 characters, and the link’s own `label`, `campaign`, `adgroup` and `creative` fill whatever the URL did not set. See [URL parameters](/links/url-parameters/). |
| `client_click_time` | string | no | ISO 8601, device clock, when the link reached the device. Dropped silently when unparseable; stored up to 64 characters. |
| `launch_referrer` | string | no | The launching app’s referrer (`Activity.getReferrer()` or `referrerURL`), up to 512 characters. |
The body’s `username` is ignored; the slug is always looked up in the caller’s own account. The request’s hashed IP and raw IP (the raw one unless the deployment sets `STORE_RAW_IP=0`), country, region, city, ASN and User-Agent (first 512 characters) are stored on the row, which is marked `origin: "sdk"`.
Response: always `200 {"success": true, "click_id": ""}`.
### What `click_id` does
[Section titled “What click_id does”](#what-click_id-does)
The intent bounce on Android delivers the web click’s id to the app, and the SDK reports it here so one tap does not count twice:
1. `click_id` names a `source: "web"` click of your account that is less than 24 hours old: that row is converted to `source: "mobile_app"`, empty fields (`session_id`, `device_id`, `device_model`, `link_params`, `client_click_time`, `launch_referrer`) are filled from the body, no new row is written and `click_count` is not bumped. The response returns that id.
2. `click_id` names a click that exists but is older than 24 hours or already converted: nothing is written. The response returns the id you sent.
3. `click_id` names no click, or is absent: a new row with a new server-generated 16-character id is inserted and the permanent link’s `click_count` goes up by one. The id you sent is not reused.
SDK clicks are never fingerprint candidates: only `source: "web"` clicks from the redirect engine are.
## POST /api/v1/app-installs
[Section titled “POST /api/v1/app-installs”](#post-apiv1app-installs)
The first-launch report. Every field is optional; a malformed optional field is stored as `null`, never rejected. The only client error is `400 {"error": {"code": "invalid_request", "message": "Invalid JSON body"}}`. The response is the verdict described below.
### Request body
[Section titled “Request body”](#request-body)
Strings are trimmed and truncated to the stated length; empty strings become `null`.
**App and OS**
| Field | Type | Meaning |
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app_platform` | string, 64 | For example `flutter`. |
| `app_version`, `app_build_number` | string, 64 | Your app’s version and build. |
| `os` | string, 32 | `android` or `ios`. This decides the device family for fingerprinting; other values disable the family filter. |
| `os_version` | string, 64 | `17.5.1`, `14`. Its major is stored as `os_major`, its minor as `os_minor`. |
| `language` | string, 64 | `en_US`; the primary tag is stored as `lang`. With a region, language and region are compared, for 0 points, with the tap’s `Accept-Language`. |
| `timezone` | string, 64 | IANA name. |
| `utc_offset_min` | integer −840 to 840 | The device’s current offset from UTC in minutes, east positive (UTC+3 is `180`). Sent outside the device tier, like `timezone`. Compared with the tap’s offset for 0 points; only an exact match agrees. |
| `metadata` | JSON | Stored as sent. |
**Device tier** (sent only when the app opted in to device data; see [Device data and privacy](/sdk/device-data-and-privacy/))
| Field | Type | Meaning |
| ------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `device_id` | string | Trimmed. Drives reinstall detection, verdict replay for the same device and the per-device rate limit. |
| `device_model` | string | Lowercased. A model present on both the click and the install and different excludes that click. |
| `device_manufacturer` | string, 64 | |
| `device_kind` | string, 16 | `phone`, `tablet`, … |
| `cpu_type` | string, 64 | `arm64-v8a`, `x86_64`; its family (`arm`, `x86`) is stored as `cpu_family`. An x86 family on an Android install of kind `phone` (or of unknown kind) raises the `emulator` flag. Never compared with a click. |
| `os_build` | string, 64 | |
| `screen_width`, `screen_height`, `screen_density` | string, 16 | Physical pixels and density, as strings. |
| `text_size` | integer | iOS only: the body text size in points for the user’s text-size setting: 14, 15, 16, 17 (the default), 19, 21, 23, 28, 33, 40, 47 or 53. |
| `dark_mode` | boolean | The system appearance is dark. |
| `high_contrast` | boolean | iOS only: Increase Contrast. |
| `reduce_motion` | boolean | iOS Reduce Motion; on Android, Remove animations. |
| `connectivity_type` | string, 16 | `wifi`, `cellular`, … `cellular` shortens the fingerprint window and raises the install-velocity threshold. |
| `carrier_mcc`, `carrier_mnc` | string, 8 | |
| `web_user_agent` | string, 512 | The device’s WebView User-Agent. Its `Build/` id and full `Chrome/` version are compared, for 0 points, with those of a tap from an Android in-app browser. |
| `package_name` | string, 256 | |
| `is_physical_device` | boolean | `false` raises the `emulator` flag. |
| `installer_package` | string, 256 | |
| `install_time`, `update_time`, `first_install_ts` | epoch ms | Provenance timestamps from the OS. Never used for matching. |
`text_size`, `dark_mode`, `high_contrast` and `reduce_motion` are compared with the values the link page recorded at the tap, for 0 points; only an exact match agrees. An Android install sends `dark_mode` and `reduce_motion` only. The SDK leaves out a value it cannot read rather than guess one; a value of the wrong type or out of range is stored as `null`.
**Deterministic signals**
| Field | Type | Meaning |
| ----------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `install_referrer` | string, 2048 | The raw Play or OEM store referrer. Attribution reads `dtg_click=` and `dtg_l=/` from it. |
| `referrer_store` | string | `google_play`, `huawei`, `samsung`, `xiaomi` or `vivo`. Defaults to `google_play` when `install_referrer` is present; other values are dropped. |
| `referrer_status` | string | `ok`, `organic`, `feature_not_supported`, `service_unavailable`, `developer_error`, `permission_error`, `timeout`, `error`, `not_available` or `not_android`. |
| `referrer_attempts` | integer 0 to 1000 | |
| `referrer_wait_ms` | integer 0 to 3,600,000 | |
| `referrer_click_ts`, `install_begin_ts` | epoch ms | The device-clock pair from the referrer API. Stored for review, never used for timing. |
| `referrer_click_server_ts`, `install_begin_server_ts` | epoch ms | Google’s server-side pair. The click time used for `impossible_speed`, `stale_referrer` and synthesized clicks. |
| `install_version` | string, 64 | |
| `google_play_instant` | boolean | Must be a JSON boolean; any other type stores `null`. |
| `meta_referrer`, `meta_referrer_ts` | string 512, epoch ms | Meta’s on-device install referrer. The SDK reads it when the app has a Facebook App ID: `DectogConfig.fbAppId`, else the `fb_app_id` saved on the Apps page, which the SDK gets from `GET /api/v1/sdk-config`. |
| `meta_referrer_is_ct` | boolean | Click-through (`true`) or view-through (`false`). Stored only together with `meta_referrer`. |
| `clipboard_url` | string, up to 2048 | A DecTog link found on the pasteboard. Only links of this account on this deployment are read: `https://./`, `/r//` on the root domain or the public base host, a custom-scheme URL carrying `dtg_l=/`, or the development form `dectog:///`. A link of another account, `/r/` on any other host, or an untagged scheme URL is ignored. The slug is stored as `clipboard_slug`; the raw clipboard content is never stored. |
| `asa_token` | string, 4096 | The Apple Search Ads attribution token from AdServices. |
Epoch values must fall between 1970 and 2100, otherwise they become `null`.
**Reliability fields**
| Field | Type | Meaning |
| ------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `install_attempt_id` | string matching `[A-Za-z0-9_-]{8,64}` | Idempotency key per tenant. A value that does not match the pattern is treated as absent. |
| `attempt` | integer 1 to 1000 | Which launch attempt produced this POST. |
| `created_at_client`, `sent_at_client` | ISO 8601, 64 | Device clock. |
| `install_state` | string | `install`, `reinstall` or `update`. See below. |
| `recovered_from` | string, 64 | The install id the server answered `install_not_found` for (see [Events and attribution](/api/events-and-attribution/)). A registration carrying it is stored with that id and is never fingerprint-matched. |
**Ad identifiers**
| Field | Type | Meaning |
| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `ad_id` | string | The advertising id. Stored in a separate table keyed by install id, never joined to `device_id`, deleted by the retention job. |
| `ad_tracking_limited` | boolean | Truthy stores `1`; absent or `null` stores `0`. |
### What the server does, in order
[Section titled “What the server does, in order”](#what-the-server-does-in-order)
1. **Replay.** When `install_attempt_id` matches a row of your account, the stored verdict comes back with `"replayed": true`. Nothing is written and nothing is re-matched.
2. **Reinstall flag.** `is_reinstall` is `true` when `install_state` is `reinstall`, or when the same `device_id` already has a non-reinstall install in your account. The flag does not stop matching; the dashboard excludes reinstalls from install counts.
3. **Deterministic ladder.** Signals are tried in order: store referrer, clipboard URL, Meta referrer. Each signal that carries `dtg_click=` is resolved:
* The click exists and no install holds it: match. `match_source` is `referrer` (store or Meta) or `clipboard`, `referrer_source` is the store name, `meta`, or `null` for clipboard, and `match_confidence` is `guaranteed`.
* The click is held by an install with the same `device_id`: that install’s verdict is replayed (`"replayed": true`), nothing is written.
* The click is held by another device’s install: the ladder **moves on to the next signal** and remembers the reuse.
* The click id is unknown: when the signal also carries `dtg_l=/` for an existing link (a clipboard URL names its slug by construction), a web click with that id is synthesized (`origin: "synthesized_referrer"`, `clicked_at` from the server-side referrer time, else now) and the install matches it. Otherwise the ladder moves on.
* When no signal matched and at least one was a reuse, the install gets the `click_reuse` flag and the fingerprint does not run. `click_reuse` is therefore only ever the final outcome, never a reason to skip a later signal.
4. **Fingerprint.** Runs unless `install_state` is `update`, step 3 ended in reuse, the registration carries `recovered_from`, or the install’s app turned **Probabilistic match** off. Candidates: your `source: "web"` clicks from iOS and Android, wherever the tap was sent, from the same hashed IP and the same device family (`os`), not yet credited to any install, from the last 24 hours, newest 50. A click keeps the 24 hour window only when it and the install share a device model or a screen profile to compare (present on both sides) and the install is not on `cellular`; every other click must be inside the low-entropy window (2 hours by default, overridable per app up to 720 hours and never longer than the wide window). Scoring per click: model match +40 (a mismatch excludes the click), OS major +15 or −30, language +10 or −15, screen short side within 3 % +15 or −40, same ASN +10; missing signals score 0. The iOS minor version, `text_size`, `dark_mode`, `high_contrast`, `reduce_motion` and `utc_offset_min` are compared too and recorded in the install’s explanation for 0 points (see [Attribution internals](/self-hosting/attribution-internals/)). Confidence from the ranked list: `low` when the top two are within 10 points or the top score is negative; `high` when the top is unique and has a model match, a screen agreement plus OS major, or a score of at least 15; `medium` for a unique top scoring 0 to 14. Only `high` credits the click (`match_source: "fingerprint"`); `medium` fills `routing` without credit; `low` does nothing. When step 3 already matched, the fingerprint still runs as a shadow and records whether it agrees.
5. **Link check.** A matched click whose link no longer exists makes the install organic.
6. **Fraud rules.** The flags below are evaluated and stored. When the app the verdict concerns (the credited or routed link’s app, else the install’s own app, else the oldest) has strict fraud mode on and the list is not empty, `matched` and `match_source` are zeroed; `matched_click_id`, `matched_slug`, `referrer_source` and `match_confidence` stay as evidence and the click stays consumed.
7. **Insert.** The row is written together with any synthesized click and the ad identifier. If two retries with the same `install_attempt_id` race, the unique index lets one in and the other receives the winner’s verdict with `"replayed": true`.
8. **Apple Search Ads.** A token starts its round-trip with Apple after the response is sent.
9. **Webhook.** `app.installed` goes to the webhooks that take the app the verdict concerns, after the response. See [Webhooks](/api/webhooks/).
### Strict fraud mode on the wire
[Section titled “Strict fraud mode on the wire”](#strict-fraud-mode-on-the-wire)
With strict mode on, a flagged install’s response has `"matched": false`, `"type": "organic"`, `"match_source": null` and no `username`, `slug`, `link`, `custom_params`, `click_params`, `deep_link` or `clicked_at`, while `referrer_source` and `match_confidence` (for example `"guaranteed"`) still show what was found. The slug is kept on the row as evidence but is never sent: the webhook payload has `slug`, `link`, `clicked_at`, `click_params` and `deep_link` set to `null` whenever `matched` is false. Strict mode, like the low-entropy window, the deterministic window and the webhook, is a per-app setting; see [Dashboard: Apps](/dashboard/apps/) for which app applies. See [Dashboard: Apps](/dashboard/apps/).
### The verdict
[Section titled “The verdict”](#the-verdict)
A deterministic match
```json
{
"matched": true,
"type": "non-organic",
"match_source": "referrer",
"referrer_source": "google_play",
"match_confidence": "guaranteed",
"fraud_flags": [],
"is_reinstall": false,
"install_state": "install",
"routing": {
"username": "acme",
"slug": "spring-sale",
"deep_link": "sale/42",
"click_params": { "deep_link": "sale/42", "label": null, "campaign": "spring", "adgroup": null, "creative": null, "tracker": null },
"confidence": "guaranteed",
"clicked_at": "2026-10-12T08:59:12.000Z"
},
"apple_search_ads": null,
"username": "acme",
"slug": "spring-sale",
"link": {
"slug": "spring-sale",
"target": "https://shop.example.com/sale",
"url": "https://acme.dectog.com/spring-sale",
"created_at": "2026-10-01T09:00:00.000Z",
"metadata": { "screen": "sale" }
},
"custom_params": { "screen": "sale" },
"click_params": { "deep_link": "sale/42", "label": null, "campaign": "spring", "adgroup": null, "creative": null, "tracker": null },
"deep_link": "sale/42",
"clicked_at": "2026-10-12T08:59:12.000Z",
"install_id": "3d2b6c1e-8f0a-4b7c-9d2e-5a6b7c8d9e0f"
}
```
| Field | Type | Present | Meaning |
| ----------------------- | -------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `matched` | boolean | always | The install is credited to a click. |
| `type` | string | always | `non-organic` when matched, else `organic`. |
| `match_source` | string or null | always | `referrer`, `clipboard`, `fingerprint`, or `null`. |
| `referrer_source` | string or null | always | `google_play`, `huawei`, `samsung`, `xiaomi`, `vivo`, `meta`, or `null`. |
| `match_confidence` | string or null | always | `guaranteed` (deterministic), `high`, `medium`, `low`, or `null` when nothing was found. |
| `fraud_flags` | string array | always | Empty when clean. |
| `is_reinstall` | boolean | always | |
| `install_state` | string or null | always | What the SDK reported. |
| `routing` | object or null | always | Where to route the user. Present when matched (mirrors the attribution, `confidence` equals `match_confidence`) and when the fingerprint ended at `medium` (`confidence: "medium"`, `matched` stays `false`: route the user, credit nobody). `null` otherwise. Fields: `username`, `slug`, `deep_link`, `click_params`, `confidence`, `clicked_at`. |
| `apple_search_ads` | object or null | always | `null` when no token was ever sent. See below. |
| `install_id` | string | always | The install row’s id. Keep it: claims, events and the attribution pull use it. |
| `replayed` | `true` | on replays | The verdict was stored earlier; this request wrote nothing. |
| `claim`, `claim_reason` | string | on claim responses | See the claim route. |
| `last_open` | object or null | on `GET /api/v1/attribution` only | See [Events and attribution](/api/events-and-attribution/). |
| `username`, `slug` | string | when matched | The matched link. |
| `link` | object | when matched **and** the link is permanent | `slug`, `target`, `url`, `created_at`, `metadata`. A temporary-link match has `username` and `slug` but no `link`. |
| `custom_params` | JSON or null | when matched | The permanent link’s `metadata`; `null` for temporary links or when none is set. |
| `click_params` | object or null | when matched | The matched click’s `dec_*` values: `deep_link`, `label`, `campaign`, `adgroup`, `creative`, `tracker`. `null` when the click carried none. |
| `deep_link` | string or null | when matched | Mirrors `click_params.deep_link`. |
| `clicked_at` | string or null | when matched | The matched click’s time. |
`apple_search_ads` has `status`, `attribution` (`true`, `false` or `null`) and `resolved_at`. Once Apple has answered it also carries `org_id`, `campaign_id`, `ad_group_id`, `keyword_id`, `ad_id`, `conversion_type` (`Download`, `Redownload`, `PreOrder`), `claim_type` (`Click` or `Impression`), `click_date`, `impression_date`, `country_or_region` and `supply_placement`; all of them are `null` when Apple answered `attribution: false`. Right after the install request the object is `{"status": "pending", "attribution": null, "resolved_at": null}` because Apple is asked after the response is sent.
### Fraud flags
[Section titled “Fraud flags”](#fraud-flags)
| Flag | Fires when |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `emulator` | `is_physical_device` is `false`, or the model contains `sdk`, `emulator`, `simulator`, `genymotion`, `virtual`, `gphone`, `pdv` or `avd` as a word, or an Android install of kind `phone` (or unknown kind) reports an x86 CPU. |
| `impossible_speed` | The deterministic click happened less than 10 seconds before the install request reached the server. Click time comes from `referrer_click_server_ts`, else `meta_referrer_ts`, else the click row. |
| `install_velocity_ip` | Installs from the same hashed IP in the last 24 hours reach 5 (wifi or unknown connectivity) or 25 (`cellular`). On a first launch the count is taken before the row is inserted, so the flag appears on the 6th or 26th install; on a claim the row already exists, so on the 5th or 25th. |
| `click_storm_ip` | The matched click’s hashed IP produced more than 100 clicks in the hour before that click. |
| `stale_referrer` | A referrer or clipboard match whose click is more than 30 days old. |
| `stale_clipboard` | A pasted link whose click is older than the app’s deterministic window (default 7 days): routed, not credited. |
| `stale_meta` | The same for Meta’s install referrer. |
| `click_reuse` | A deterministic signal named a click already credited to another device’s install, and nothing later matched. |
Without strict mode the flags are annotations only; attribution stands.
### `install_state`
[Section titled “install_state”](#install_state)
| Value | Effect |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `install` | Normal first launch. |
| `reinstall` | `is_reinstall` is `true`. Matching still runs. |
| `update` | An existing user updated into a build that includes the SDK. The fingerprint never runs, so the install cannot take a click from the pool. Deterministic signals still apply. |
## POST /api/v1/app-installs/:id/claim
[Section titled “POST /api/v1/app-installs/:id/claim”](#post-apiv1app-installsidclaim)
Hands in a deterministic signal that arrived after the first launch: a store referrer the service delivered on a later launch, a pasted link, a late Meta read, or an Apple Search Ads token. `:id` is the `install_id` from the verdict; an id that is not yours answers `404 {"error": {"code": "not_found", "message": "Install not found"}}`.
The body accepts the **Deterministic signals** fields above, plus `attempt` and `claim_source`. Device, app and reliability fields are ignored here.
| Field | Type | Meaning |
| -------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `claim_source` | string | `late_referrer`, `paste_control` or `clipboard`. Any other string answers `400 {"error": {"code": "invalid_request", "message": "claim_source must be one of late_referrer, paste_control, clipboard"}}`. Stored as `claim_source` on the install when the claim upgrades it. |
| `attempt` | integer 1 to 1000 | Stored as the larger of the sent and stored values. |
Every call refreshes telemetry first: non-null signal fields overwrite the stored ones, `clipboard_slug` is set when it was empty, and `updated_at` is stamped. The verdict changes at most once. The response is the verdict plus `claim` and, except on `upgraded`, `claim_reason`:
| `claim` | `claim_reason` | When | What you get back |
| ----------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `unchanged` | `already_deterministic` | The row already holds a deterministic click (`match_source` other than `fingerprint`), including a strict-mode zeroed one. | The stored verdict. |
| `unchanged` plus `"replayed": true` | `already_deterministic` | The named click is held by another install of the same `device_id`. | That install’s verdict; the SDK adopts its `install_id`. |
| `upgraded` | none | The signal names a usable click. The deterministic click replaces a fingerprint guess (the guessed click is released) or organic. `claimed_at` is set, fraud rules are re-evaluated and merged with the stored flags, strict mode may zero the credit again, and the webhook fires `app.install_updated`. | The new verdict with `"match_confidence": "guaranteed"`. |
| `rejected` | `click_reuse` | The click is held by another device’s install. `click_reuse` is added to `fraud_flags`. | The stored verdict. |
| `rejected` | `click_not_found` | The click id is unknown and no `dtg_l` of yours could synthesize it. | The stored verdict. |
| `rejected` | `no_signal` | No signal in the body carries `dtg_click`. | The stored verdict. |
| `rejected` | `stale_click` | The pasted link’s or Meta referrer’s click is older than the app’s deterministic window (default 7 days). `stale_clipboard` or `stale_meta` is added to `fraud_flags`. | The stored verdict. |
Claims never run the fingerprint: a claim with no deterministic signal is `rejected / no_signal` even when a fingerprint candidate exists.
An `asa_token` on a claim is accepted unless the row’s status is already `resolved` or `no_attribution`; it sets the status back to `pending` with zero attempts and starts the round-trip, including on `unchanged` and `rejected` claims.
A referrer that arrived on the third launch
```bash
dectog POST /api/v1/app-installs/3d2b6c1e-8f0a-4b7c-9d2e-5a6b7c8d9e0f/claim '{
"install_referrer": "dtg_click=4f1e9c2b7a3d5e6f&dtg_l=acme/spring-sale",
"referrer_store": "google_play",
"referrer_status": "ok",
"referrer_click_server_ts": 1760259552000,
"install_begin_server_ts": 1760259601000,
"claim_source": "late_referrer",
"attempt": 3
}'
```
## Apple Search Ads
[Section titled “Apple Search Ads”](#apple-search-ads)
`asa_token` is exchanged server-side with Apple’s attribution API. The result is provenance only: it never changes `matched`, which is about DecTog links. See [Deterministic attribution](/sdk/deterministic-attribution/) for the SDK side.
| `status` | Meaning |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `pending` | A token arrived and Apple has not answered yet. |
| `resolved` | Apple answered `attribution: true`. The record fields are filled and the webhook fires `app.install_updated`. |
| `no_attribution` | Apple answered `attribution: false` (no record, or an age or gender targeted ad group). Record fields are `null`. No webhook. |
| `invalid` | Apple answered HTTP 400: the token is malformed. Not retried. |
| `expired` | More than 24 hours passed since the token reached the server. Marked without calling Apple. |
| `failed` | No token was available when the server tried to resolve. |
How the round-trip runs:
* The install or claim response answers before Apple does, with `"status": "pending"`.
* Inline, after the response: up to 3 attempts, 5 seconds apart (`ASA_RETRY_DELAY_MS`). HTTP 200 settles the row as `resolved` or `no_attribution`; 400 settles it as `invalid`; 404 (token minted too recently) is retried on the next attempt; a 5xx, a network error or a malformed body stops the inline attempts at once and leaves the row `pending`.
* Both scheduled jobs (hourly at :07 and daily at 03:17 UTC) take up to 100 pending tokens, oldest first, and make one attempt each. Tokens older than 24 hours are marked `expired` without a call. See [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/).
* Settling clears the token from the row; `asa_attempts` keeps the count.
* Only `resolved` and `no_attribution` are final: a later token on a `pending`, `invalid`, `expired` or `failed` row restarts the process; a later token on a settled row is ignored.
# Events and attribution
> Batched post-install events, the attribution pull, device pings and app-open reports.
Four routes carry what happens after the install. The Flutter SDK calls them for you (see [Events](/sdk/events/)); this page is the wire contract. All four need the Bearer key and request signature from [API overview](/api/overview/) and are scoped to your account.
## POST /api/v1/events
[Section titled “POST /api/v1/events”](#post-apiv1events)
Stores a batch of events and, through `identify`, your own customer identities.
| Field | Type | Required | Meaning |
| ------------ | ----------- | -------- | ---------------------------------------------------------------- |
| `device_id` | string, 128 | no | The device the batch comes from. Keys the per-device rate limit. |
| `install_id` | string, 64 | no | The `install_id` from the install verdict. |
| `events` | array | yes | 1 to 100 event objects. |
Each event:
| Field | Type | Required | Meaning |
| ------------------------- | ------------------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | string | yes | `identify`, `lead`, `conversion`, `custom` or `screen_view`. |
| `event_name` | string, 200 | `screen_view` only | Free text, for example `purchase` or `signup`; for a `screen_view`, the screen. |
| `revenue` | number | `conversion` only (not for a cancel) | Must be finite and not negative. Stored as sent, and also converted into the account’s revenue currency (`revenue_base`). |
| `currency` | string, 8 | no | Upper-cased on storage (`usd` becomes `USD`). Totals convert it with the ECB reference rate of the event’s day; no currency means the account’s currency. |
| `customer_id` | string, 200 | `identify` only | Your user id. |
| `metadata` | JSON | no | Stored as sent. |
| `client_timestamp` | string, 64 | no | Device clock. |
| `transaction_id` | string, 128 | no | Conversions: the store transaction. Stored once per `store` and `transaction_type`; a repeat is a duplicate. |
| `original_transaction_id` | string, 128 | no | Conversions: the subscription’s first purchase. A renewal, refund or cancel inherits that purchase’s `install_id` and link. |
| `product_id` | string, 200 | no | Conversions. |
| `store` | `app_store` or `play` | no | Conversions. |
| `purchase_type` | `one_time` or `subscription` | no | Conversions. |
| `quantity` | integer 1 to 10000 | no | Conversions. |
| `transaction_type` | `purchase`, `refund` or `cancel` | no | Conversions, default `purchase`. A refund subtracts its `revenue`; a cancel counts nothing and needs no `revenue`. |
| `open_slug` | string, 128 | no | The link that last opened the app before this event. With it, `open_at` is required. Not stored when it names no link of the account, or a link deleted, paused or expired before `open_at`: an open `/app-opens` refused takes no credit. |
| `open_click_id` | string, 8 to 32 of `A-Z a-z 0-9 - _` | no | That open’s click id. Anything else is dropped; the event is still stored. |
| `open_at` | ISO 8601 date | with `open_slug` | When that link opened the app. A time after the request is stored as the request time. |
| `session_id` | string, 64 | no | The app process the event came from (the SDK sends a new id per process). A `screen_view` is stored only with one. |
| `app_store_jws` | string, 16384 | no | Conversions: a StoreKit 2 transaction (`jwsRepresentation`). Verified against Apple Root CA - G3; its `transactionId`, `originalTransactionId`, `productId`, price and currency replace the event’s, `store` becomes `app_store`, and the row is stored with `source: "store"`. Its bundle ID must be one of the account’s apps. With it, `revenue` is optional. |
| `client_event_id` | string, 8 to 64 of `A-Z a-z 0-9 - _` | no | Your id for the event, unique per account. A resend with an id the account already stored is a duplicate and changes nothing. The SDK sends a UUID it stamps when the event is queued. |
| `name`, `email`, `phone` | string 200, 254, 32 | no | Read on `identify` only. |
String values are trimmed and truncated to the stated length; empty strings become `null`.
### Batch rules
[Section titled “Batch rules”](#batch-rules)
* Each event stands on its own. An invalid event is listed in `rejected` with its index in the batch and the error; the other events are stored.
* An event whose `client_event_id` the account already stored, or that repeats an id earlier in the same batch, counts in `duplicates` and is not stored again. This is what makes a resend after a lost response safe: a purchase is never counted twice.
* The response is `200 {"accepted": n, "duplicates": n, "rejected": [{"index": i, "error": "…"}]}`. `accepted` counts the events stored and every valid `screen_view`, kept or not.
* When the batch names an `install_id` the account does not have (after a database restore, a retention purge, or a build pointed at a fresh database), the events are still stored and the answer adds `"code": "install_not_found", "action": "reregister"`. The SDK then registers the install again.
* When every event of the batch is invalid the answer is `400` with the same list: `{"error": {"code": "invalid_request", "message": "Every event in the batch is invalid", "rejected": [...]}}`.
* More than 100 events answer `413 {"error": {"code": "payload_too_large", "message": "events batch exceeds 100 items"}}`.
| Status | Body |
| ------ | ------------------------------------------------------------------------------------------------------------- |
| 400 | `{"error": {"code": "invalid_request", "message": "Invalid JSON body"}}` |
| 400 | `{"error": {"code": "invalid_request", "message": "events must be a non-empty array"}}` |
| 400 | `{"error": {"code": "invalid_request", "message": "Every event in the batch is invalid", "rejected": [...]}}` |
| 413 | `{"error": {"code": "payload_too_large", "message": "events batch exceeds 100 items"}}` |
The per-event errors in `rejected`:
| Error |
| ------------------------------------------------------------------------------- |
| `each event must be an object` |
| `event.type must be one of identify, lead, conversion, custom` |
| `identify events require customer_id` |
| `revenue must be a non-negative number` |
| `conversion events require revenue` |
| `metadata is not JSON-serializable` |
| `client_event_id must be 8 to 64 letters, digits, '-' or '_'` |
| `transaction_type must be one of purchase, refund, cancel` |
| `store must be one of app_store, play` |
| `purchase_type must be one of one_time, subscription` |
| `quantity must be a whole number from 1 to 10000` |
| `open_at must be an ISO 8601 date` |
| `app_store_jws must be a JWS of at most 16384 characters` |
| `app_store_jws: ` (the signature, chain or dates do not verify) |
| `app_store_jws names no transaction` |
| `app_store_jws names an app this account does not have (set its iOS bundle ID)` |
| `app_store_jws carries no price; send revenue too` |
### What each type does
[Section titled “What each type does”](#what-each-type-does)
* **`identify`** upserts the customer row keyed by (`account`, `customer_id`). `name`, `email` and `phone` are stored **in plain text**. On a repeat `identify`, a non-null value in the new call overwrites the stored one and an omitted field keeps its stored value; `first_install_id` is set once, from the first `identify` that carried an `install_id`, and never replaced. The `identify` is also stored as an event row of type `identify`. See [Dashboard: Customers](/dashboard/customers/).
* **Automatic signup lead.** The first `identify` of a customer the account has not seen before, sent from a fresh install (the batch’s `install_id`, else the device’s latest install, created within the last 30 days and not a reinstall), also stores a `lead` event with `event_name: "signup"` and `metadata: {"auto": true, "source": "identify"}`. A `lead` with `event_name: "signup"` that you post for a customer who already has one is counted in `duplicates` and not stored.
* **`screen_view`** is a screen the app showed. It needs `event_name` and refuses `revenue`. It is never stored as an event: the batch’s screen views go, once per flush, onto the row of their app session (`session_id`), which keeps the device, install, customer and link open (`open_slug`, `open_click_id`, `open_at`) the session is first known by, the device time of its first screen, the screen count and the first 50 screens with their times, in the order shown. A session that starts before sign-in becomes the customer’s at the `identify`, screens before it included; a session keeps the link open that started it. A screen view without a `session_id` is accepted and not stored. Screen views count in no event total or list, and are not in the raw events export; the per-subject export, erasure and retention carry the session rows. Per link, the dashboard lists the screens reached in the 30 minutes after the link opened the app (`GET /api/admin/links/:slug/screens`, on [Admin API](/api/admin-api/#links)); per customer and per install, the 20 newest sessions with the first 50 screens of each (`screen_sessions` on `GET /api/admin/customers/:customerId` and `/installs/:id`).
* **`lead`**, **`conversion`**, **`custom`** are stored as sent. `conversion` requires `revenue` unless it is a `cancel`. A conversion whose `store` + `transaction_id` + `transaction_type` the account already has is a duplicate. Net revenue, the figure every dashboard total shows, adds purchases and subtracts refunds, in the account’s revenue currency. See [Revenue](/sdk/revenue/).
Every stored event carries `matched_slug`, the slug of the link that brought the install, so per-link leads and revenue need no join at read time. Per-link numbers credit an event to `open_slug` instead when it happened within the re-engagement window after that open: the window of the re-opening link’s app (`reengagement_window_hours`, the default app’s for a link bound to none), else `REENGAGEMENT_WINDOW_HOURS` (default 168 hours). It is resolved per event: through `install_id` when the batch names one (only if that install is matched; there is no fallback to the device for an organic `install_id`), else through the device’s latest matched install. The request’s raw IP is stored on the event row unless the deployment sets `STORE_RAW_IP=0`.
Identify a user and record a purchase
```bash
dectog POST /api/v1/events '{
"device_id": "4b1d8c3e9f2a",
"install_id": "3d2b6c1e-8f0a-4b7c-9d2e-5a6b7c8d9e0f",
"events": [
{ "type": "identify", "customer_id": "u_1042", "name": "Ada", "email": "ada@example.com", "client_event_id": "u_1042-identify-1" },
{ "type": "conversion", "event_name": "purchase", "revenue": 12.5, "currency": "usd", "customer_id": "u_1042", "client_event_id": "order-88231" }
]
}'
```
Response: `{"accepted": 2, "duplicates": 0, "rejected": []}`. Sending the same body again answers `{"accepted": 0, "duplicates": 2, "rejected": []}`.
## POST /api/v1/store/apple/:token
[Section titled “POST /api/v1/store/apple/:token”](#post-apiv1storeappletoken)
App Store Server Notifications v2 for one app. `:token` comes from the dashboard (**Apps → Store notifications**); there is no API key. The body is Apple’s `{"signedPayload": ""}`. The notification and the `signedTransactionInfo` inside it must verify against Apple Root CA - G3 and name the app’s iOS bundle ID. What each notification type records is on [Revenue](/sdk/revenue/#store-notifications).
| Status | Body |
| ------ | ------------------------------------------------------------------------------------------------------------------------ |
| 200 | `{"ok": true, "outcome": "recorded"}`; `outcome` is `recorded`, `updated`, `duplicate`, `ignored`, `unmatched` or `test` |
| 400 | `signedPayload is required`, or the payload has no `notificationUUID` |
| 401 | `{"error": {"code": "invalid_signature", "message": "signedPayload: "}}` |
| 404 | `Unknown store notification URL` |
| 422 | `{"error": {"code": "bundle_mismatch", …}}` |
## POST /api/v1/store/play/:token
[Section titled “POST /api/v1/store/play/:token”](#post-apiv1storeplaytoken)
Google Play real-time developer notifications, pushed by a Pub/Sub push subscription with authentication on. The `Authorization: Bearer` token must be a Google-signed OIDC token whose audience is this URL and whose email is the service account saved for the app. The body is Pub/Sub’s `{"message": {"data": "", "messageId": "…"}, "subscription": "…"}`; the decoded notification must name the app’s Android package.
| Status | Body |
| ------ | -------------------------------------------- |
| 200 | `{"ok": true, "outcome": "…"}` as above |
| 400 | `The body is not a Pub/Sub push message` |
| 401 | `{"error": {"code": "invalid_token", …}}` |
| 403 | No service account saved for the app |
| 404 | `Unknown store notification URL` |
| 422 | `{"error": {"code": "package_mismatch", …}}` |
## GET /api/v1/attribution
[Section titled “GET /api/v1/attribution”](#get-apiv1attribution)
Returns the stored verdict for a device or an install, so an app can recover it after a restart or a failed first call. The body is the verdict from [Clicks and installs](/api/clicks-and-installs/) plus `last_open`.
| Query parameter | Meaning |
| --------------- | ------------------------------------------------------------------------------------------------------ |
| `install_id` | The install to return. Wins when both parameters are given. |
| `device_id` | The device’s latest **matched** install, else its latest install. Also keys the per-device rate limit. |
| Status | Body |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `{"error": {"code": "invalid_request", "message": "device_id or install_id query parameter is required"}}` |
| 404 | `{"error": {"code": "not_found", "message": "No install found for this device"}}`: no install exists for that device. |
| 404 | `{"error": {"code": "install_not_found", "message": "This install is not known to the server; register it again", "action": "reregister"}}`: the `install_id` names no install of yours. |
| 200 | The verdict. An organic install answers 200 with `"matched": false`; 404 only means “no install at all”. |
`last_open` is always present: `{"username", "slug", "click_id", "opened_at"}` for the latest app open reported for this device (the query’s `device_id`, or the install’s own) or for this install id, else `null`.
Re-pull the verdict without a device id
```bash
dectog GET '/api/v1/attribution?install_id=3d2b6c1e-8f0a-4b7c-9d2e-5a6b7c8d9e0f'
```
## POST /api/v1/device-ping
[Section titled “POST /api/v1/device-ping”](#post-apiv1device-ping)
Marks the device active today: one `device_days` row per device and UTC day, written by the day’s first ping; later pings that day write nothing. The SDKs send it once a day. It feeds the retention numbers.
| Field | Type | Required | Meaning |
| ------------- | ----------- | -------- | ------------------------------------------------------------------------------------------------- |
| `device_id` | string, 128 | yes | Missing answers `400 {"error": {"code": "invalid_request", "message": "device_id is required"}}`. |
| `app_version` | string, 64 | no | |
| `os` | string, 32 | no | |
Response: `200 {"ok": true}`. Device-day rows are deleted by the retention job; see [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/).
## POST /api/v1/app-opens
[Section titled “POST /api/v1/app-opens”](#post-apiv1app-opens)
Reports that a DecTog link opened the already-installed app (a warm open through an App Link, a Universal Link, a custom scheme or an intent bounce). Feeds the per-link Opens count and `last_open`.
| Field | Type | Required | Meaning |
| ------------- | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `slug` | string, 128 | yes | One of your permanent or temporary slugs. Missing answers `400 {"error": {"code": "invalid_request", "message": "slug is required"}}`; unknown answers `404 {"error": {"code": "not_found", "message": "Link not found"}}`. |
| `click_id` | string, 64 | no | The `dtg_click` id from an intent bounce. Stored when it has the click-id shape (8 to 32 of `A-Z a-z 0-9 _ -`), otherwise dropped while the open is still recorded. It is not checked against the clicks table. |
| `device_id` | string, 128 | no | |
| `install_id` | string, 64 | no | |
| `app_version` | string, 64 | no | |
| `os` | string, 32 | no | |
| `username` | string | no | Ignored. The slug is always looked up in the caller’s own account. |
The request’s hashed IP is stored on the row. Response: `200 {"ok": true}`.
# Export raw data
> Page through your clicks, installs, app opens and events with the account key, download them as CSV from the dashboard, and how exports past 50,000 rows become a file in an R2 bucket.
Your clicks, installs, app opens and events are yours to take elsewhere: a warehouse, a spreadsheet, another attribution tool. There are two ways out.
* **The v1 export routes**, for scripts and pipelines: every row of the account, oldest first, a page at a time, with the account key.
* **CSV from the dashboard**, for a person: the Overview’s Breakdown table, or the raw rows behind it with the same window and filters. See [Dashboard: Overview](/dashboard/overview/#breakdown).
Both leave out the raw network identifiers: the IP (raw or hashed) and the user agent. They are what attribution matched on, not data about your users you need to reconcile.
## Page through the rows
[Section titled “Page through the rows”](#page-through-the-rows)
| Route | Rows |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/clicks` | Every tap on a link, and every click the SDK logged |
| `GET /api/v1/app-installs` | Every install report: first installs, reinstalls (`is_reinstall`) and installs that were app updates into an SDK build (`install_state` `update`). The Overview’s Installs count only first installs. |
| `GET /api/v1/app-opens` | Every time a link opened the installed app |
| `GET /api/v1/events` | Every event, with the link it counts for (`credited_slug`) |
These routes need the **account key** (`dtg_…`): an SDK key ships inside your app, so it answers `403` with code `account_key_required`. Sign the request like any other v1 call ([Request signing](/api/overview/#request-signing)); a GET signs the empty body.
| Parameter | Default | Meaning |
| --------- | ------- | ---------------------------------------- |
| `since` | none | Only rows at or after this ISO 8601 time |
| `until` | none | Only rows before this ISO 8601 time |
| `limit` | 100 | Page size, 1 to 1000 |
| `cursor` | none | `next_cursor` of the previous page |
The answer is `{data, next_cursor}`: rows in the order they were recorded (time, then id), and the cursor for the next page, `null` on the last one. A bad `cursor`, `since` or `until`, or a `limit` out of range, answers `400`.
Every click since the start of October, as JSON lines
```bash
cursor=""
while :; do
page=$(dectog GET "/api/v1/clicks?since=2026-10-01T00:00:00Z&limit=1000${cursor:+&cursor=$cursor}")
echo "$page" | jq -c '.data[]'
cursor=$(echo "$page" | jq -r '.next_cursor // empty')
[ -z "$cursor" ] && break
done
```
`dectog` is the signed curl helper from [Request signing](/api/overview/#request-signing), with `DECTOG_API_KEY` set to the account key; the same page has the headers in Node.
To keep a copy current, remember the time of the last row you stored and ask with `since` set to it on the next run. Rows with that exact time come again; skip the ids you already have.
Raw rows live for the retention window (`RETENTION_DAYS`, 400 days by default); older ones are gone. See [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/).
### Columns
[Section titled “Columns”](#columns)
JSON columns (`link_params`, `fraud_flags`, `metadata`) arrive parsed; `is_reinstall`, `matched` and `via_open` are booleans. A CSV has the same columns in the same order, JSON columns as JSON text.
| Rows | Columns |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Clicks | `id`, `clicked_at`, `link_slug`, `link_kind`, `source`, `origin`, `device_type`, `os`, `os_major`, `browser`, `device_model`, `device_id`, `country`, `region`, `city`, `lang`, `referrer`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `link_params`, `routing_rule`, `cta`, `cta_at`, `bot_score`, `sdk_name`, `sdk_version`, `metadata` |
| Installs | `id`, `created_at`, `os`, `os_version`, `app_version`, `app_build_number`, `package_name`, `lang`, `timezone`, `device_id`, `device_model`, `device_manufacturer`, `is_reinstall`, `matched`, `matched_slug`, `matched_click_id`, `match_source`, `match_confidence`, `install_state`, `fraud_flags`, `asa_status`, `claimed_at`, `claim_source`, `sdk_name`, `sdk_version`, `metadata` |
| App opens | `id`, `created_at`, `slug`, `click_id`, `device_id`, `install_id`, `app_version`, `os` |
| Events | `id`, `created_at`, `type`, `event_name`, `revenue`, `currency`, `revenue_base`, `transaction_type`, `purchase_type`, `store`, `product_id`, `transaction_id`, `original_transaction_id`, `quantity`, `install_id`, `device_id`, `customer_id`, `matched_slug`, `source`, `web_click_id`, `credited_slug`, `via_open`, `client_event_id`, `client_timestamp`, `metadata` |
`credited_slug` is the link an event counts for: the link that last opened the app, when the event came within its re-engagement window, else the install’s link (`matched_slug`). `revenue_base` is the amount in the account’s revenue currency. A refund keeps its amount positive with `transaction_type` `refund`; totals subtract it.
## CSV
[Section titled “CSV”](#csv)
`GET /api/admin/export.csv` takes the parameters of the [analytics query](/api/admin-api/#analytics) (`interval` or `start` and `end`, `tz`, and the filters) plus `data`:
| `data` | The file |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `analytics` (default) | The analytics answer as a table: a column per `group_by` dimension, then one per metric, then `conversion` when both clicks and installs are in it |
| `clicks`, `installs`, `opens`, `events` | The raw rows of the window that pass the filters, oldest first. Installs include reinstalls and updates. A filter on a dimension those rows do not have, such as clicks by `match_source`, answers `400`. |
It is a session route: the dashboard’s **Export CSV** button and **Raw rows** menu call it. The file starts with a UTF-8 byte order mark so Excel reads accents right, separates rows with CRLF, and quotes a field holding a comma, a quote or a line break. A text that starts with `=`, `+`, `-` or `@` gets a leading `'`, so a spreadsheet shows a campaign called `=HYPERLINK(…)` instead of running it. Numbers are left alone.
Up to 50,000 rows (`EXPORT_STREAM_MAX_ROWS`) stream back at once, with the row count in `x-dectog-rows`.
### Larger exports
[Section titled “Larger exports”](#larger-exports)
Past the limit, what happens depends on whether the deployment has an `EXPORTS` R2 bucket ([Configuration reference](/self-hosting/configuration-reference/)).
* **Without one** the export answers `422` with code `export_too_large`, `rows` and `max_rows`, and a message naming the ways out: a shorter window, tighter filters, or the paged route above.
* **With one** it answers `202` with a job, `{id, data, status: "queued", total_rows, row_count, bytes, error, created_at, finished_at, expires_at, status_url, download_url: null}`, and writes the file in the background. `GET /api/admin/exports/:id` reports the job; once `status` is `done`, `download_url` is a signed link to the file, valid until `expires_at`, seven days after the file was written. The link carries its own credential (`?exp=&sig=`), so it works from a script or a mail without a session; a wrong or expired one answers `403` with code `invalid_download_link`.
The request that asked starts the job, and most files are written before it ends. One that is not finished then is taken over by the next hourly cron, from the start, at most three attempts in all; after the third failure the job is `failed` with the `error`. The window is the one of the moment you asked, however long the job takes. The daily cron deletes a file seven days after it was written (the job turns `expired`) and the job a week after that.
# Links
> Create, read, update and delete permanent and temporary links over the REST API, and resolve any link without an API key.
A link is a short URL on your account’s host, `https://.dectog.com/`. Permanent links live until you delete them or give them an expiry, and carry routing, social-preview and tracking settings. Temporary links expire after a TTL and carry only a target and a social preview. Both kinds can be paused, archived, deleted and restored ([Pause, expire, archive and delete](#pause-expire-archive-and-delete)).
Every route on this page except `resolve` needs the Bearer key and the request signature described in [API overview](/api/overview/). All routes are scoped to the account that owns the key: you can only see and change your own links.
## The Link object
[Section titled “The Link object”](#the-link-object)
Every permanent-link route returns the full object, including fields you never set (they come back as `null`).
Link
```json
{
"id": "6f1c2a0e-3b7d-4c8e-9a11-2f5d8c0b4e77",
"slug": "spring-sale",
"target": "https://shop.example.com/sale",
"url": "https://acme.dectog.com/spring-sale",
"app_id": "0d4e5f66-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
"click_count": 0,
"created_at": "2026-10-12T09:00:00.000Z",
"updated_at": null,
"metadata": { "screen": "sale", "campaign": "spring" },
"og_title": null,
"og_description": null,
"og_image_url": null,
"ios_deep_link_path": "sale",
"android_deep_link_path": "sale",
"redirect_url": null,
"fallback_url": null,
"label": null,
"campaign": "spring",
"adgroup": null,
"creative": null,
"ios_tap_to_store": null,
"mobile_interstitial": null,
"append_click_id": null,
"routing_rules": null,
"template_id": null,
"utm_source": null,
"utm_medium": null,
"utm_campaign": null,
"utm_term": null,
"utm_content": null,
"external_id": null,
"referrer_customer_id": null,
"expires_at": null,
"expired_url": null,
"disabled_at": null,
"disabled_reason": null,
"archived_at": null,
"deleted_at": null
}
```
| Field | Type | Meaning |
| --------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | string | UUID, assigned by the server. |
| `slug` | string | The path segment of the link. |
| `target` | string or null | The web destination. `null` means “inherit the app’s fallback URL at tap time”. |
| `url` | string | The public link URL in this deployment’s URL mode (subdomain on the hosted service, `/r//` in path mode). |
| `app_id` | string or null | The app whose store URLs, schemes and fallback apply to this link. `null` means the account’s default app (its oldest app). |
| `click_count` | number | Lifetime counter. It grows on web taps, on SDK-reported clicks and on clicks synthesized from a store referrer. It never goes down. |
| `created_at`, `updated_at` | string or null | ISO 8601 UTC. `updated_at` stays `null` until the first `PUT`. |
| `metadata` | JSON or null | Your own data. Delivered to the app on attributed installs as `custom_params` and inside `link.metadata`. |
| `og_title`, `og_description`, `og_image_url` | string or null | Social preview overrides for bots and unfurlers. Fall back to the app’s values. See [Social previews and QR](/links/social-previews-and-qr/). |
| `ios_deep_link_path`, `android_deep_link_path` | string or null | The app route a custom-scheme open uses on that platform: `scheme://?dtg_l=/` instead of `scheme:///?dtg_l=…`. Stored without a leading slash. |
| `redirect_url` | string or null | Mobile visitors without the app go here instead of the store. A store URL here is still tagged with the click id. |
| `fallback_url` | string or null | Where desktop visitors go instead of the QR page, and the last mobile fallback. |
| `label`, `campaign`, `adgroup`, `creative` | string or null | Tracking defaults copied onto every click of this link. A `dec_` query parameter on the tapped URL wins for that tap. See [URL parameters](/links/url-parameters/). |
| `ios_tap_to_store`, `mobile_interstitial` | boolean or null | This link’s iOS tap-to-store page and mobile interstitial. `null` follows the app’s setting. See [How a tap is routed](/links/how-a-tap-is-routed/#mobile-delivering-it). |
| `append_click_id` | boolean or null | Whether the link adds `dtg_click` to its web destinations, for [`dectog.js`](/sdk/web/). `null` is off, or the template’s value. |
| `routing_rules` | array or null | Geo and language rules, first match wins. See [Geo and language rules](#geo-and-language-rules). |
| `template_id` | string or null | The [template](#templates) whose values fill every field this link leaves empty. The fields above are the link’s own. |
| `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | string or null | Added to the link’s web destinations and stored on clicks whose URL has none. See [Link-level UTM values](/links/url-parameters/#link-level-utm-values). |
| `external_id` | string or null | Your own id for the link (a product or CMS row id), unique in the account. See [Upsert and external ids](#upsert-and-external-ids). |
| `referrer_customer_id` | string or null | Set on a customer’s referral link. See [Referral programs](/guides/referral-programs/). |
| `expires_at`, `expired_url` | string or null | When the link expires, and where taps go after that instead of a `410` page. |
| `disabled_at`, `disabled_reason` | string or null | Paused since, and your note on why. A paused link answers `410` and records nothing. |
| `archived_at` | string or null | Filed away in the dashboard. The link keeps working. |
| `deleted_at` | string or null | Deleted. Only listed with `include_deleted`. |
## Slug rules
[Section titled “Slug rules”](#slug-rules)
* Allowed characters: `A-Z`, `a-z`, `0-9`, `_` and `-`. Length 1 to 128.
* Reserved: `healthz`, `readyz`, `bulk` and `upsert` (any case), and any slug starting with `ext_`, which names a link by its external id.
* The slug is used exactly as sent. Leading or trailing whitespace makes it invalid. Slugs are case-sensitive: `Sale` and `sale` are two different links.
* One namespace per account for permanent **and** temporary links. Creating either kind with a slug the other kind holds returns `409 {"error": {"code": "conflict", "message": "Slug already exists", "slug": ""}}`.
* A slug becomes free again only when the link is deleted permanently. The daily job’s delete of an expired temporary link is a soft delete, so it keeps its slug. A deleted link keeps its slug, because its history is filed under it: creating that slug answers `409 {"error": {"code": "conflict", "message": "Slug belongs to a deleted link: restore it, or delete it permanently to reuse the slug", "slug": "", "deleted": true}}`.
## Create a link
[Section titled “Create a link”](#create-a-link)
`POST /api/v1/links` returns `201` with the Link.
| Field | Type | Required | Meaning |
| ---------------------------------------------- | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `slug` | string | yes | See the slug rules above. |
| `target` | string or null | no | An http(s) URL. `null`, `""` or absent stores `null` (inherit the app’s fallback URL). |
| `app_id` | string | no | The id of one of your apps. Default: your oldest app, or `null` when the account has no app yet. |
| `metadata` | JSON object | no | Your own key-value data. `null` or `""` stores `null`; any other non-object value (a string, number, boolean or array) answers `400 {"error": {"code": "invalid_request", "message": "metadata must be a JSON object"}}`. |
| `og_title`, `og_description` | string | no | Trimmed; at most 150 characters each. `""` stores `null`. |
| `og_image_url` | string | no | An http(s) URL. `""` stores `null`. |
| `ios_deep_link_path`, `android_deep_link_path` | string | no | A relative route such as `products/42?color=black`. At most 512 characters; no `:`, whitespace, `#`, `;` or control characters. A leading `/` is stripped. `""` stores `null`. |
| `redirect_url`, `fallback_url` | string | no | http(s) URLs. `""` stores `null`. |
| `label`, `campaign`, `adgroup`, `creative` | string | no | Trimmed; at most 255 characters; no control characters. `""` stores `null`. |
| `ios_tap_to_store`, `mobile_interstitial` | boolean or null | no | `true` or `false` overrides the app’s setting for this link; `null` or absent follows it. Anything else answers `400 Invalid ios_tap_to_store: must be true, false or null (follow the app)`. |
| `append_click_id` | boolean or null | no | `true` adds `dtg_click=` to the link’s web destinations (never stores, MMP links or schemes); `false` or `null` does not. A template can set it. Anything else answers `400 Invalid append_click_id: must be true, false or null`. |
| `routing_rules` | array or null | no | Up to 20 rules; `null` or `[]` stores none. See below. |
| `template_id` | string | no | One of your templates; anything else answers `400 Unknown template_id`. When `app_id` is absent, the template’s app applies. |
| `utm_source` … `utm_content` | string | no | Trimmed; at most 255 characters; no control characters. `""` stores `null`. |
| `external_id` | string | no | 1 to 255 characters, no control characters. Taken by another link: `409 {"error": {"code": "conflict", "message": "external_id belongs to another link", …}}`. |
| `expires_at` | string | no | An ISO 8601 time such as `2026-12-31T23:59:00Z`, stored in UTC. A time in the past creates an already-expired link. |
| `expired_url` | string | no | An http(s) URL taps go to once the link has expired. |
| `disabled`, `disabled_reason` | boolean, string | no | `true` creates the link paused. The reason is at most 255 characters. |
| `archived` | boolean | no | `true` creates the link archived. |
Checks run in this order and stop at the first failure:
| Status | Body |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `{"error": {"code": "invalid_request", "message": "Invalid JSON body"}}` |
| 400 | `{"error": {"code": "invalid_request", "message": "slug is required"}}` |
| 400 | `{"error": {"code": "invalid_request", "message": "Invalid slug: only letters, numbers, hyphens and underscores are allowed"}}` |
| 400 | `{"error": {"code": "invalid_request", "message": "Slug 'healthz' is reserved"}}`: `healthz` and `readyz` (any case) are answered by the service itself on every host, so they are refused for permanent and temporary links |
| 400 | `{"error": {"code": "invalid_request", "message": "Invalid target: must be a valid http(s) URL"}}` |
| 400 | `{"error": {"code": "invalid_request", "message": "disabled must be true or false"}}`, `"archived must be true or false"`, `"expires_at must be an ISO 8601 time, e.g. 2026-12-31T23:59:00Z"`, `"expired_url must be a valid http(s) URL"`, `"disabled_reason: max 255 characters"` |
| 409 | `{"error": {"code": "conflict", "message": "Slug already exists", "slug": "spring-sale"}}`, or the deleted-link variant above |
| 400 | `{"error": {"code": "invalid_request", "message": "Unknown app_id"}}` (the id is not one of your apps) |
| 400 | `{"error": {"code": "invalid_request", "message": "Invalid label: max 255 characters"}}`, `{"error": {"code": "invalid_request", "message": "Invalid campaign: control characters are not allowed"}}`, `{"error": {"code": "invalid_request", "message": "Invalid adgroup: must be a string"}}` (checked in the order `label`, `campaign`, `adgroup`, `creative`) |
| 400 | `{"error": {"code": "invalid_request", "message": "Invalid ios_deep_link_path: must be a relative path, not a full URL (no \":\": no scheme, no spaces, no \"#\"/\";\")"}}`, `{"error": {"code": "invalid_request", "message": "Invalid android_deep_link_path: deep link path is too long (max 512 chars)"}}` |
| 400 | `{"error": {"code": "invalid_request", "message": "Invalid redirect_url: must be a valid http(s) URL"}}`, same for `fallback_url` |
The server also rejects `og_title` or `og_description` longer than 150 characters and an `og_image_url` that is not an http(s) URL, with the `400` strings listed under [Update a link](#update-a-link).
Create a link
```bash
dectog POST /api/v1/links '{
"slug": "spring-sale",
"target": "https://shop.example.com/sale",
"metadata": { "screen": "sale" },
"ios_deep_link_path": "sale",
"android_deep_link_path": "sale",
"campaign": "spring"
}'
```
`dectog` is the signing helper from [API overview](/api/overview/).
## Create many links
[Section titled “Create many links”](#create-many-links)
`POST /api/v1/links/bulk` with `{"links": [ … ]}` creates up to 100 links, each with the create fields above. Every link succeeds or fails on its own, and the call answers `200`:
POST /api/v1/links/bulk
```json
{
"created": [ { "slug": "spring-1", "url": "https://acme.dectog.com/spring-1", … } ],
"errors": [ { "index": 1, "slug": "spring-1", "code": "conflict", "error": "Duplicate slug or external_id in this batch" } ]
}
```
`created` holds full Link objects; `errors` names each failed item by its position in `links`, with the same `code` and message a single create would give. A slug or external id that appears twice in the batch fails from its second appearance. An empty array, more than 100 links, or a body without `links` answers `400`. The dashboard’s **Import CSV** and the MCP `create_links` tool use this route.
## Upsert and external ids
[Section titled “Upsert and external ids”](#upsert-and-external-ids)
A link created with `external_id` can be named by it anywhere a v1 link route takes a slug: `GET`, `PUT` and `DELETE /api/v1/links/ext_` and `POST /api/v1/links/ext_/restore`, so a system that knows its own ids never has to store slugs.
`PUT /api/v1/links/upsert` makes a sync idempotent. It finds the link by `external_id` when the body has one, else by `slug`:
| Found | Result |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A live link | Updated with the `PUT` rules (absent keeps, `null` or `""` clears); `200` with the Link. A `slug` in the body that differs from the found link’s answers `400`: a link’s slug never changes. |
| A deleted link | `409` with `"deleted": true`: restore it first |
| Nothing | Created with the create rules (`slug` is then required); `201` with the Link |
Sync one product's link
```bash
dectog PUT /api/v1/links/upsert '{"external_id": "product-42", "slug": "p42", "target": "https://shop.example.com/p/42", "campaign": "catalog"}'
```
## Templates
[Section titled “Templates”](#templates)
A template holds defaults for link fields. A link with `template_id` takes the template’s value for every field it leaves empty, at tap time, so editing a template changes all its links at once. The link’s own values always win; see [the precedence](/links/how-a-tap-is-routed/#which-value-applies).
| Route | Response |
| ----------------------------------- | ----------------------------------------------------------------------------------- |
| `GET /api/v1/link-templates` | `200 {"data": [Template, …], "next_cursor": null}`, by name |
| `POST /api/v1/link-templates` | `{name, app_id?, defaults}` → `201` Template; a name already in use answers `409` |
| `GET /api/v1/link-templates/:id` | `200` Template or `404 Template not found` |
| `PUT /api/v1/link-templates/:id` | any of `name`, `app_id`, `defaults` (which replaces the whole set) → `200` Template |
| `DELETE /api/v1/link-templates/:id` | `200 {"success": true}`; its links keep their own fields and stop inheriting |
Template
```json
{
"id": "1b2c…",
"name": "Spring campaign",
"app_id": null,
"defaults": { "fallback_url": "https://shop.example.com/spring", "campaign": "spring", "utm_source": "newsletter" },
"link_count": 12,
"created_at": "2026-10-14T09:00:00.000Z",
"updated_at": null
}
```
`defaults` may hold `target`, `redirect_url`, `fallback_url`, `ios_deep_link_path`, `android_deep_link_path`, `og_title`, `og_description`, `og_image_url`, `label`, `campaign`, `adgroup`, `creative`, the five `utm_*` fields, `ios_tap_to_store` and `mobile_interstitial`, each validated like the link field; any other key answers `400`. `name` is 1 to 100 characters; `app_id`, when set, is the app new links get when they name none. Templates use the links scopes: reading needs `links:read`, changing `links:write`. Responses to `GET /api/v1/links/:slug` show the link’s own fields, not the inherited ones; `resolve`, the redirect and the install verdict use the inherited values.
## List and read links
[Section titled “List and read links”](#list-and-read-links)
| Route | Response |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /api/v1/links?limit=&cursor=&include_deleted=` | `200 {"data": [Link, …], "next_cursor": "…" \| null}`, newest first. `limit` is 1 to 200 (default 50); pass `next_cursor` back as `cursor` for the next page until it is `null`. A `limit` out of range, or a `cursor` this API did not return, answers `400`. Paused, expired and archived links are listed; deleted ones only with `include_deleted=true`. |
| `GET /api/v1/links/:slug` | `200` with the Link, or `404 {"error": {"code": "not_found", "message": "Link not found"}}`. A temporary link’s slug and a deleted link also answer 404 here. |
## Update a link
[Section titled “Update a link”](#update-a-link)
`PUT /api/v1/links/:slug` applies a partial update and returns `200` with the full Link. Unknown slugs and deleted links return `404 {"error": {"code": "not_found", "message": "Link not found"}}`. The lifecycle fields of the create body work here too; see the next section.
| In the body | Effect |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Key absent | The stored value is kept. This includes the `og_*` fields. |
| `null` | Clears the field: `target`, `metadata`, `og_*`, the deep-link paths, `redirect_url`, `fallback_url` and the four tracking fields all become `null`. |
| `""` | Clears the field, `metadata` included. |
| `slug`, `app_id` | Ignored. Neither can be changed after creation. |
Validation is the same as on create. Errors come back as `400` with the same strings, plus `{"error": {"code": "invalid_request", "message": "og_title must be at most 150 characters"}}`, `{"error": {"code": "invalid_request", "message": "og_description must be at most 150 characters"}}` and `{"error": {"code": "invalid_request", "message": "og_image_url must be a valid http(s) URL"}}`.
Clear the target so the link inherits the app fallback
```bash
dectog PUT /api/v1/links/spring-sale '{"target": null, "label": "launch-week"}'
```
## Geo and language rules
[Section titled “Geo and language rules”](#geo-and-language-rules)
A rule replaces some of the link’s fields for taps from given countries or browser languages. Rules are tried in order and the first match wins; a tap no rule matches is routed as usual, and the URL’s `dec_*` parameters still win over a rule. See [How a tap is routed](/links/how-a-tap-is-routed/#geo-and-language-routing).
| Rule field | Type | Rules |
| ---------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `name` | string | Up to 64 letters, digits, spaces and `. _ : -`; recorded on each click the rule routes (`routing_rule`), else `rule N` |
| `countries` | array of strings | ISO 3166 two-letter codes, any case, stored upper case |
| `languages` | array of strings | Language tags; only the primary tag is kept, lower case (`de-AT` becomes `de`) |
| `percent` | integer | An A/B share of visitors, 1 to 100. Shares stack in rule order; together at most 100. A visitor’s share follows their hashed IP. |
| `starts_at`, `ends_at` | string | ISO 8601 times bounding the rule (scheduled routing); `ends_at` after `starts_at` |
| `target`, `redirect_url`, `fallback_url` | string | http(s) URLs that replace the link’s own for a matching tap |
| `ios_deep_link_path`, `android_deep_link_path` | string | In-app routes, with the same rules as the link’s |
A rule needs a condition (`countries`, `languages`, `percent`, `starts_at` or `ends_at`; a tap must meet every one a rule has) and at least one of the destination fields. Unknown fields, more than 20 rules, shares over 100, or a bad value answer `400`, for example `{"error": {"code": "invalid_request", "message": "routing_rules[0] needs a condition: countries, languages, percent, starts_at or ends_at"}}`.
German-speaking visitors get the German sale page and in-app route
```bash
dectog PUT /api/v1/links/spring-sale '{"routing_rules": [
{"countries": ["DE", "AT", "CH"], "fallback_url": "https://shop.example.de/sale", "ios_deep_link_path": "de/sale", "android_deep_link_path": "de/sale"},
{"languages": ["fr"], "fallback_url": "https://shop.example.fr/soldes"}
]}'
```
Half the visitors see the new landing page; compare by routing_rule
```bash
dectog PUT /api/v1/links/spring-sale '{"routing_rules": [
{"name": "new-landing", "percent": 50, "fallback_url": "https://shop.example.com/new", "redirect_url": "https://shop.example.com/new-m"}
]}'
```
## Pause, expire, archive and delete
[Section titled “Pause, expire, archive and delete”](#pause-expire-archive-and-delete)
A link that is not live records nothing: no click row, no click count, for people and bots alike. The SDK’s click and app-open reports for it are refused the same way, after the app has already opened.
| State | Set with | A tap gets | `resolve`, SDK clicks and opens |
| -------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Paused | `PUT {"disabled": true, "disabled_reason": "…"}`; `{"disabled": false}` resumes and clears the reason | A `410` “Link paused” page. The reason is never shown. | `410 {"error": {"code": "link_paused", …}}` |
| Expired | `expires_at` in the past, on create or `PUT`; `null` or `""` removes it | A `302` to `expired_url`, or a `410` “Link expired” page without one | `410 {"error": {"code": "link_expired", "expired_url": "…" \| null, …}}` |
| Archived | `PUT {"archived": true}`; `false` unarchives | The usual routing: archiving only files the link away in the dashboard | As a live link |
| Deleted | `DELETE /api/v1/links/:slug` | A `404` page, as for a slug that never existed | `404 not_found` |
Pausing a paused link keeps its first `disabled_at`. A paused link that has also expired answers as paused.
End a sale at midnight and send late taps to a page
```bash
dectog PUT /api/v1/links/spring-sale '{"expires_at": "2026-12-31T23:59:00Z", "expired_url": "https://shop.example.com/sale-over"}'
```
### Delete, restore, delete permanently
[Section titled “Delete, restore, delete permanently”](#delete-restore-delete-permanently)
`DELETE /api/v1/links/:slug` returns `200 {"success": true}`. Unknown slugs, and links already deleted, return `404 {"error": {"code": "not_found", "message": "Link not found"}}`.
Deleting is a soft delete. The link leaves the list (unless `include_deleted=true`), its URL answers 404, and `GET` and `PUT` answer 404. Its clicks and the installs, events and app opens attributed to it stay, so the Overview and every past verdict keep their numbers. The link keeps its slug. Thirty days after the delete, the daily job anonymises its clicks and app opens (IP hash, device id, user agent and the like), as retention would do after `RETENTION_DAYS`; installs and events keep the normal schedule.
`POST /api/v1/links/:slug/restore` brings a deleted link back and returns `200` with the Link; restoring a live link changes nothing. An unknown slug answers 404.
`DELETE /api/v1/links/:slug?permanent=true` deletes the link and its history in one atomic batch, on a live or a deleted link: the events of installs it attributed and events denormalized to its slug, its app opens and referrals, the installs attributed through its clicks (customers that pointed at those installs keep their identity but lose `first_install_id`), its clicks, then the link. The dashboard’s totals drop accordingly, and the slug is free again. This cannot be undone; see [Dashboard: Links](/dashboard/links/).
Note
The dashboard’s cookie-session route `DELETE /api/admin/links/:slug` (with `?permanent=1` for the hard delete) answers `{"deleted": true}` even for a slug that does not exist. Only the v1 route returns 404.
## Temporary links
[Section titled “Temporary links”](#temporary-links)
Temporary links expire after a TTL. They have no app binding, no deep-link paths, no tracking defaults and no click counter; taps use the account’s default app for store routing and fallback. See [Temporary links](/links/temporary-links/) for how they behave when tapped.
TempLink
```json
{
"id": "9a7b5c3d-1e2f-4a6b-8c9d-0e1f2a3b4c5d",
"slug": "t_4f1e9c2b7a",
"target": "https://example.com/build/1234",
"url": "https://acme.dectog.com/t_4f1e9c2b7a",
"expires_at": "2026-10-12T10:00:00.000Z",
"created_at": "2026-10-12T09:00:00.000Z",
"ttl_seconds": 3600,
"og_title": null,
"og_description": null,
"og_image_url": null,
"expired_url": null,
"disabled_at": null,
"disabled_reason": null,
"archived_at": null,
"deleted_at": null
}
```
### Create a temporary link
[Section titled “Create a temporary link”](#create-a-temporary-link)
`POST /api/v1/temp-links` returns `201` with the TempLink.
| Field | Type | Required | Meaning |
| -------------------------------------------------------- | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target` | string or null | no | Same rules as for permanent links. |
| `slug` | string | no | Same rules as for permanent links. When absent, `null` or `""`, the server generates `t_` followed by 10 lowercase hex characters, for example `t_4f1e9c2b7a`. |
| `ttlSeconds` | number | no | Lifetime in seconds. Default 900 (15 minutes). |
| `og_title`, `og_description`, `og_image_url` | string | no | Same rules as for permanent links. |
| `expired_url`, `disabled`, `disabled_reason`, `archived` | | no | As on permanent links. The expiry is always the TTL: `expires_at` in the body answers `400`. |
How `ttlSeconds` is interpreted:
* It must be a JSON number when present, otherwise `400 {"error": {"code": "invalid_request", "message": "ttlSeconds must be a number"}}`.
* The value is floored to a whole number. `0`, or anything that floors to `0` (for example `0.5`), becomes `900`.
* Everything else is clamped to the range 60 to 2,592,000 (30 days): `30` becomes `60`, `-5` becomes `60`, `1e15` becomes `2592000`.
* `expires_at` is `created_at` plus the resulting `ttl_seconds`.
Other errors: `400 {"error": {"code": "invalid_request", "message": "Invalid slug"}}`, `400 {"error": {"code": "invalid_request", "message": "Invalid target: must be a valid http(s) URL"}}`, `409 {"error": {"code": "conflict", "message": "Slug already exists", "slug": "…"}}`. Invalid `og_*` values are rejected as on permanent-link create.
A link for a CI build that expires in one hour
```bash
dectog POST /api/v1/temp-links '{"target": "https://example.com/build/1234", "ttlSeconds": 3600}'
```
### Read, update and delete
[Section titled “Read, update and delete”](#read-update-and-delete)
| Route | Response |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /api/v1/temp-links/:slug` | `200` TempLink, or `404 {"error": {"code": "not_found", "message": "Temporary link not found"}}`. |
| `PUT /api/v1/temp-links/:slug` | Partial update of `target`, the three `og_*` fields and the lifecycle fields; absent keeps, `null` or `""` clears. Returns `200` TempLink, or `404 {"error": {"code": "not_found", "message": "Temporary link not found"}}`. The TTL and `expires_at` cannot be changed. |
| `DELETE /api/v1/temp-links/:slug` | `200 {"success": true}` after a soft delete, as for a permanent link; `?permanent=true` deletes the link with the same history cascade. `404 {"error": {"code": "not_found", "message": "Temporary link not found"}}` for a slug that is not yours. |
| `POST /api/v1/temp-links/:slug/restore` | `200` TempLink after restoring a deleted temporary link, or `404`. |
There is no list route for temporary links in the v1 API. The dashboard’s `GET /api/admin/links` returns them under `temp_links`.
### Lifecycle after expiry
[Section titled “Lifecycle after expiry”](#lifecycle-after-expiry)
1. From `expires_at` on, a tap goes to the link’s `expired_url`, or answers the `410` “Link expired” page, for bots too. No click is recorded. The link still exists.
2. `resolve` answers `410 link_expired`; `GET /api/v1/temp-links/:slug` keeps answering `200`.
3. The daily job (03:17 UTC) deletes temporary links whose `expires_at` is more than 24 hours in the past, except those with an `expired_url`, which keep sending taps there until you delete them. Their clicks and installs are kept, and the slug is free again. From then on the link URL answers 404 and the API answers `404`.
4. The dashboard’s “Delete expired” button (`DELETE /api/admin/temp-links/expired`) removes every expired temporary link of the account immediately, also keeping their history.
## Resolve a link (public)
[Section titled “Resolve a link (public)”](#resolve-a-link-public)
`GET /api/v1/resolve/:username/:slug` needs no API key, no signature and is not rate limited. It is what the SDK calls when it opens a link, and it works for any account’s links. CORS allows every origin.
GET /api/v1/resolve/acme/spring-sale
```json
{
"id": "6f1c2a0e-3b7d-4c8e-9a11-2f5d8c0b4e77",
"slug": "spring-sale",
"target": "https://shop.example.com/sale",
"url": "https://acme.dectog.com/spring-sale",
"type": "permanent",
"created_at": "2026-10-12T09:00:00.000Z",
"click_count": 12,
"expires_at": null,
"ttl_seconds": null,
"metadata": { "screen": "sale", "campaign": "spring" },
"ios_deep_link_path": "sale",
"android_deep_link_path": "sale"
}
```
Every key is always present. Which ones carry values depends on the kind of link:
| Field | Permanent link | Temporary link |
| ---------------------------------------------- | --------------------- | -------------- |
| `type` | `"permanent"` | `"temporary"` |
| `click_count` | number | `null` |
| `expires_at` | the expiry, or `null` | value |
| `ttl_seconds` | `null` | value |
| `metadata` | JSON or `null` | `null` |
| `ios_deep_link_path`, `android_deep_link_path` | string or `null` | `null` |
`id`, `slug`, `target`, `url` and `created_at` are filled for both kinds. Only a live link resolves: a paused one answers `410 {"error": {"code": "link_paused", "message": "This link is paused"}}`, an expired one `410 {"error": {"code": "link_expired", "message": "This link has expired", "expired_url": "…"}}` (`null` without one), and an unknown username or slug, or a deleted link, `404 {"error": {"code": "not_found", "message": "Link not found"}}`. The `og_*`, routing and tracking fields are not exposed here.
# API overview
> Base URLs, Bearer keys, request signing, rate limits, CORS, the error shape and conventions of the DecTog REST API, with a complete signed curl example.
The REST API under `/api/v1` is what the Flutter SDK talks to, and what you use from scripts and backends to manage links or read attribution. An account has three kinds of key: the account key for servers and scripts, SDK keys for the apps you ship, and site keys for your web pages. This page covers what every route has in common; the routes themselves are on the following pages.
## Base URLs
[Section titled “Base URLs”](#base-urls)
| Deployment | API base | Link URLs |
| ----------- | ------------------------ | ---------------------------------------------- |
| Hosted | `https://app.dectog.com` | `https://.dectog.com/` |
| Self-hosted | your `PUBLIC_BASE_URL` | subdomain or path mode, per your configuration |
Self-hosted deployments
Every example in this section uses the hosted base. A self-hosted deployment uses its own `PUBLIC_BASE_URL` (for example `http://localhost:8787` during development) and, in path mode, link URLs of the form `/r//`. See [Configuration reference](/self-hosting/configuration-reference/).
## Routes at a glance
[Section titled “Routes at a glance”](#routes-at-a-glance)
| Route | Auth | Page |
| ------------------------------------------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------- |
| `POST`, `GET`, `PUT`, `DELETE /api/v1/links[/:slug]` | key + signature | [Links](/api/links/) |
| `POST`, `GET`, `PUT`, `DELETE /api/v1/temp-links[/:slug]` | key + signature | [Links](/api/links/) |
| `GET /api/v1/resolve/:username/:slug` | none | [Links](/api/links/) |
| `POST /api/v1/clicks` | key + signature | [Clicks and installs](/api/clicks-and-installs/) |
| `POST /api/v1/app-installs`, `POST /api/v1/app-installs/:id/claim` | key + signature | [Clicks and installs](/api/clicks-and-installs/) |
| `POST /api/v1/events`, `GET /api/v1/attribution`, `POST /api/v1/device-ping`, `POST /api/v1/app-opens` | key + signature | [Events and attribution](/api/events-and-attribution/) |
| `GET /api/v1/clicks`, `GET /api/v1/app-installs`, `GET /api/v1/app-opens`, `GET /api/v1/events` | account key + signature | [Export raw data](/api/export/) |
| `GET /api/v1/web-config`, `POST /api/v1/web-events` | site key, from an allowed origin | [Web pixel](/sdk/web/) |
| `GET /api/v1/devices/:id/export`, `DELETE /api/v1/devices/:id`, the same for `/api/v1/customers/:id` | account key + signature | [A request from one person](/self-hosting/privacy-and-data/#a-request-from-one-person) |
| Webhooks (outbound) | HMAC signature | [Webhooks](/api/webhooks/) |
| `/api/admin/*`, `/api/auth/*` | session cookie, and a team role | [Admin API](/api/admin-api/) |
| `/.well-known/*`, `/robots.txt`, `/sitemap.xml`, `/healthz`, `/readyz` | none | [Well-known files, SEO and routing](/api/well-known-seo-and-routing/) |
## Authentication
[Section titled “Authentication”](#authentication)
Every `/api/v1` route except `resolve` requires a key as a Bearer token:
```http
Authorization: Bearer dtg_pub_0123456789abcdef0123456789abcdef
```
There are three kinds of key, all managed in [Dashboard: Settings](/dashboard/settings/). Settings shows SDK and site keys again; the account key is shown only once, when it is created or regenerated:
| Key | Looks like | Use it in | Scopes |
| --------------- | --------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **SDK key** | `dtg_pub_` and 32 hex characters | The apps you ship. Anyone can extract it from a build. | `sdk` (installs, claims, clicks, events, opens, the attribution pull), `temp_links`, `links:read`; plus `links:write` only for a key created with that option |
| **Site key** | `dtg_site_` and 32 hex characters | Your web pages, with [`dectog.js`](/sdk/web/). Public. | Only the two web routes, from the origins it lists |
| **Account key** | `dtg_` and 32 hex characters | Your servers and scripts. Never in an app. | Everything, including creating, changing and deleting links, and the only key that can [export raw rows](/api/export/) |
| Scope | Routes |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sdk` | `POST /api/v1/app-installs` and its claim, `POST /api/v1/clicks`, `POST /api/v1/events`, `GET /api/v1/attribution`, `POST /api/v1/device-ping`, `POST /api/v1/app-opens` |
| `temp_links` | every `/api/v1/temp-links` route |
| `links:read` | `GET /api/v1/links` and `GET /api/v1/links/:slug` |
| `links:write` | `POST`, `PUT` and `DELETE` on `/api/v1/links` |
* A new account gets its account key and one SDK key, “App key”. Create more SDK keys (one per app or per build channel) and revoke them in Settings; an account holds up to 20 active SDK keys.
* The server stores only each key’s SHA-256 and looks the account up by that hash. A missing or malformed key answers `401` with code `missing_api_key`, an unknown key `invalid_api_key`, a revoked SDK key `revoked_api_key`, before anything else is checked. A key without the route’s scope answers `403` with code `insufficient_scope` and the `required_scope`. An edge location keeps an SDK key and its account for a minute after looking them up (`SDK_AUTH_CACHE_SECONDS`, see [Configuration reference](/self-hosting/configuration-reference/)), so a key revoked a moment ago can still be accepted there until the minute is up.
* Revoking an SDK key stops every build that ships it within a minute; release a build with a new key first. Regenerating the account key affects only servers and scripts.
* Keep the account and SDK keys out of source control where you can, and only ever send them to your DecTog base URL.
## Request signing
[Section titled “Request signing”](#request-signing)
Every authenticated `/api/v1` request must also carry a signature that binds the API key, a timestamp and the exact request body. The Flutter SDK does this for you; any other client has to do it by hand.
| Header | Value |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `x-dectog-timestamp` | The current Unix time in **whole seconds**, as a decimal string. Fractions or non-numeric values are rejected. |
| `x-dectog-signature` | `v1=` followed by the lowercase hex HMAC described below. The comparison is exact: uppercase hex, whitespace or a missing `v1=` prefix fail. |
What is signed:
```text
signature = HMAC-SHA256( key = the API key, message = "." )
```
* The body is hashed exactly as the bytes you send. For `GET` and `HEAD` the body is always the empty string, whatever the request carries. For `DELETE` without a body it is also the empty string.
* The timestamp must be within 1,800 seconds (30 minutes) of the server clock, in either direction. The signature binds the timestamp to the body, so inside that window a captured request can only be repeated as it was, never altered. Creates answer 409 on a repeat and installs replay their stored verdict; clicks and events are recorded again, so do not retry those blindly.
* The checks run in this order and stop at the first failure: Bearer key, then signature, then the account rate limit, then the device or IP rate limit, then the route handler.
| Status | `code` | `message` | When |
| ------ | -------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| 401 | `missing_api_key` | `Missing API key` | No Bearer key, or not in the `Bearer ` form. |
| 401 | `invalid_api_key` | `Invalid API key` | The key is not one the server knows. |
| 401 | `revoked_api_key` | `This API key was revoked` | An SDK key revoked in Settings. |
| 403 | `insufficient_scope` | `This key does not have the scope` | An SDK key used on a route outside its scopes; the error carries `required_scope`. |
| 401 | `signature_required` | `SDK request signature required (upgrade flutter_dectog)` | Either signature header is missing. |
| 401 | `stale_timestamp` | `SDK request timestamp is stale or invalid` | The timestamp is not an integer or is more than 1,800 s from server time. |
| 401 | `invalid_signature` | `Invalid SDK request signature` | The HMAC does not match (wrong key, wrong message, re-encoded body, uppercase hex). |
### Worked example
[Section titled “Worked example”](#worked-example)
With the key `dtg_0123456789abcdef0123456789abcdef`, the timestamp `1760263200` and this exact body (no trailing newline):
```text
{"slug":"spring-sale","target":"https://shop.example.com/sale"}
```
| Step | Value |
| -------------------- | ----------------------------------------------------------------------------- |
| `sha256hex(body)` | `7468e8047a020694d8d57a0624e98ff1d867571f7b4c343053d9f4a99d193478` |
| message | `1760263200.7468e8047a020694d8d57a0624e98ff1d867571f7b4c343053d9f4a99d193478` |
| `x-dectog-signature` | `v1=a3d6b8723a69d7101b01d2842d9a9bb38ec752b011c4865015890433c9f449c7` |
For a `GET` with the same key and timestamp the body hash is the SHA-256 of the empty string, `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`, and the header is `v1=6fbbe17e676c07b8f74aa49c22de054da6f0aa5997501f453399428508e6d328`. Use these values to check a port of the signing code.
### Signing with curl
[Section titled “Signing with curl”](#signing-with-curl)
The helper below computes the signature with `openssl` and works in bash and zsh. It is the `dectog` command used in the examples on the other API pages.
A signed curl helper
```bash
export DECTOG_API_KEY=dtg_0123456789abcdef0123456789abcdef
export DECTOG_API=https://app.dectog.com
# dectog [JSON body]
dectog() {
local method=$1 route=$2 body=${3:-}
local ts body_hash sig
local -a data=()
ts=$(date +%s)
body_hash=$(printf '%s' "$body" | openssl dgst -sha256 | sed 's/^.* //')
sig=$(printf '%s' "$ts.$body_hash" | openssl dgst -sha256 -hmac "$DECTOG_API_KEY" | sed 's/^.* //')
[ -n "$body" ] && data=(--data-binary "$body")
curl -sS --fail-with-body -X "$method" "$DECTOG_API$route" \
-H "Authorization: Bearer $DECTOG_API_KEY" \
-H 'Content-Type: application/json' \
-H "x-dectog-timestamp: $ts" \
-H "x-dectog-signature: v1=$sig" \
"${data[@]}"
}
```
Create, list and delete a link
```bash
dectog POST /api/v1/links '{"slug":"spring-sale","target":"https://shop.example.com/sale","metadata":{"screen":"sale"}}'
# {"id":"6f1c2a0e-…","slug":"spring-sale","target":"https://shop.example.com/sale","url":"https://acme.dectog.com/spring-sale",…}
dectog GET /api/v1/links | jq -r '.data[] | "\(.slug)\t\(.click_count)\t\(.url)"'
dectog DELETE /api/v1/links/spring-sale
# {"success":true}
```
`--data-binary` sends the body byte for byte, which is what the hash covers. `-d @file` would strip newlines and break the signature; so does any proxy or library that re-serializes JSON.
The same computation in Node:
```js
import { createHash, createHmac } from "node:crypto";
export function dectogHeaders(apiKey, rawBody = "") {
const ts = Math.floor(Date.now() / 1000).toString();
const bodyHash = createHash("sha256").update(rawBody).digest("hex");
const sig = createHmac("sha256", apiKey).update(`${ts}.${bodyHash}`).digest("hex");
return {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
"x-dectog-timestamp": ts,
"x-dectog-signature": `v1=${sig}`,
};
}
```
## OpenAPI
[Section titled “OpenAPI”](#openapi)
`GET /openapi.json` serves an OpenAPI 3.1 description of every route, generated from the same table the server’s tests check against the routes it answers. Routes marked `x-internal` serve the dashboard and may change without notice. Point a client generator or an agent at it: `https://app.dectog.com/openapi.json` on the hosted service.
## MCP
[Section titled “MCP”](#mcp)
`POST /mcp` is a Model Context Protocol server for AI agents, authenticated with the same Bearer keys and without request signing. It is described in [Automation and agents](/guides/automation-and-agents/#mcp-server), not in the OpenAPI document.
## SDK identity
[Section titled “SDK identity”](#sdk-identity)
SDKs also send `x-dectog-sdk: /`: `flutter_dectog/1.0.0`, `react-native-dectog/1.0.0`. The server stores it as `sdk_name` and `sdk_version` on the install, on each event and on in-app clicks, and the Overview lists the versions in use. Without the header it reads the `DectogSDK/` token of the User-Agent. Scripts and servers need not send either.
A self-hosted deployment can set `MIN_SDK_VERSION` to retire old builds, per SDK (`flutter_dectog/1.1.0, react-native-dectog/1.0.2`) or as a bare version for every SDK without its own entry (`1.1.0`): an identified SDK older than its minimum answers `400` with code `sdk_too_old` and the `min_version`. An SDK the setting does not name, and callers that do not identify as an SDK, are not checked.
## Rate limits
[Section titled “Rate limits”](#rate-limits)
Every authenticated `/api/v1` response reports the account limit in `x-ratelimit-limit` (3000). They describe the account counter only; the device and IP counters below can refuse a request first.
The hosted service, and a self-hosted deployment set up with `npm run setup`, count the account, device, IP and beacon limits with Cloudflare’s Rate Limiting bindings: one-minute windows kept per Cloudflare location, so the limits are approximate, and the response carries only `x-ratelimit-limit`. A deployment without those bindings in its Wrangler config (local development, an older config) counts them in D1 instead: fixed windows aligned to the wall clock (the current minute, `floor(now / 60 s)`), reset by the first request of a new minute, where a rejected request still counts; there the response also carries `x-ratelimit-remaining` and `x-ratelimit-reset` (seconds until the window ends). Several counters apply to one request.
| Limit | Window | Counter key | Applies to |
| -------------- | ------ | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| 3,000 requests | 60 s | the account id | every authenticated `/api/v1` request |
| 60 requests | 60 s | `dev::` (first 128 characters) | requests that name a device: `device_id` in the JSON body, or `?device_id=` on `GET` and `HEAD` |
| 600 requests | 60 s | `ip::` | authenticated requests that name no device, so every link and temp-link call and any install or event call sent without `device_id` |
| 120 requests | 60 s | `webcollect:` | `POST /_/collect`, the interstitial beacon |
| 5 requests | 1 hour | `contact:` | `POST /api/contact` (its 429 carries no `Retry-After`) |
The hashed IP is `sha256(IP_SALT + ":" + ip)` with IPv6 addresses cut to their /64 prefix, so a whole IPv6 network shares one counter.
Over a limit, `/api/v1` answers `429` with code `rate_limited` and a `Retry-After` header holding the number of seconds until the current minute ends (1 to 60). Wait at least that long before retrying. The IP counter is per account and IP, so one account’s traffic never consumes another account’s allowance.
Fail-open and fail-closed
If the rate-limit counter cannot be written (a database or binding failure), the account, device and IP limiters **let the request through** and log the failure: the limiter never takes the API down with it. The beacon limiter does the opposite and answers 429 when its D1 counter write fails.
`GET /api/v1/resolve/:username/:slug` is not rate limited.
## CORS
[Section titled “CORS”](#cors)
| Prefix | `access-control-allow-origin` | Other headers |
| --------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/v1/*` (including `resolve`) | `*` | `access-control-allow-headers: authorization, content-type, x-dectog-timestamp, x-dectog-signature, x-dectog-sdk, x-request-id`; `access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS`; `access-control-expose-headers: x-request-id` |
| `/api/admin/*` | `WEB_URL` (`https://app.dectog.com`) | `access-control-allow-credentials: true`; `vary: Origin`; `access-control-allow-headers: authorization, content-type, x-request-id`; same methods; `access-control-expose-headers: x-request-id` |
`OPTIONS` on any `/api/*` path answers `204`. The v1 API never uses cookies, so browser clients only need the headers above.
## Errors
[Section titled “Errors”](#errors)
Every error, on every route, is one JSON shape:
```json
{
"error": {
"code": "conflict",
"message": "Slug already exists",
"request_id": "8d2f4c1b9a7e6f30-AMS",
"slug": "spring-sale"
}
}
```
* `code` is a stable, machine-readable reason. Branch on it, not on `message`.
* `message` is for people and may be reworded. It names the field that failed validation.
* `request_id` identifies the request. The same value is in the `x-request-id` response header, which every response carries, errors or not. Quote it when you report a problem: it finds the request in the server’s logs. Send your own `x-request-id` (8 to 128 characters from `A-Z a-z 0-9 . _ : -`) and the server uses it instead of generating one.
* Some errors carry extra keys next to `message`, such as `slug` on a 409.
| Status | `code` | Meaning |
| ------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `invalid_request` | Validation failed, or the body is not valid JSON (`Invalid JSON body`). The message names the field. |
| 400 | `sdk_too_old` | The calling SDK build is older than the deployment’s `MIN_SDK_VERSION`; the error carries `min_version`. |
| 401 | `missing_api_key`, `invalid_api_key`, `signature_required`, `stale_timestamp`, `invalid_signature` | Authentication or signature failed; see above. |
| 401 | `not_authenticated`, `invalid_session` | Admin API only: no session cookie, or a bad or expired one. |
| 403 | `forbidden` | Admin API only: the route needs the owner role, or bootstrap is not permitted. |
| 404 | `not_found` | The resource is not in your account (`Link not found`, `Temporary link not found`, `Install not found`, `No install found for this device`), or no route matches. |
| 409 | `conflict` | The slug is taken (`Slug already exists`, with the `slug`), or the instance is already bootstrapped. |
| 413 | `payload_too_large` | An events batch has more than 100 items. |
| 429 | `rate_limited` | Rate limited; see above. |
| 500 | `internal_error` | A server fault. The response never carries a stack trace; the `request_id` finds it in the logs. |
The route pages quote errors as `{"error": {"code", "message"}}` and leave out `request_id`, which every error carries. Messages are quoted exactly as the server sends them.
## Conventions
[Section titled “Conventions”](#conventions)
* Request and response bodies are JSON. Send `Content-Type: application/json`. Responses are `application/json; charset=utf-8`.
* Creates (`POST /api/v1/links`, `POST /api/v1/temp-links`) answer `201`. Everything else that succeeds answers `200`, including `DELETE`, which returns `{"success": true}` rather than `204`.
* Timestamps are ISO 8601 in UTC with milliseconds, `2026-10-12T09:00:00.000Z`. Epoch fields in install payloads are milliseconds.
* Ids are UUIDs, except click ids, which are 16 hexadecimal characters.
* Nullable fields are always present in responses and come back as `null`, never omitted. Fields that exist only in some situations (for example the attribution fields of a matched install) are omitted when they do not apply; the route pages say which.
* `GET /api/v1/links` and the [raw export routes](/api/export/) return one page at a time, `{data, next_cursor}`: pass `next_cursor` back as `cursor` until it is `null`. The export routes also filter by time with `since` and `until`, and the links list takes `include_deleted`. `GET /api/v1/link-templates` returns every template in one page.
* Routes do not use trailing slashes.
## Health check
[Section titled “Health check”](#health-check)
`GET /healthz` needs no authentication, is not rate limited, and answers on every host:
```json
{ "ok": true, "service": "dectog", "version": "v1", "host": "app.dectog.com" }
```
`host` echoes the request’s Host header. Use it for uptime checks instead of a link URL. A tap check on a real link should send `x-dectog-no-track: 1` so it is not counted.
`GET /readyz` is the readiness check. It runs `SELECT 1` against D1 and compares wrangler’s `d1_migrations` table with the migrations bundled into this build:
```json
{ "ok": true, "database": true, "migrations": { "expected": 62, "applied": 62, "missing": [] } }
```
It answers `503` with `ok: false` when D1 does not answer or a migration is missing, and lists the missing file names. It sends `cache-control: no-store`. Use it in deploy smoke tests: `/healthz` passes even with a broken database binding.
# Webhooks
> The signed POSTs DecTog sends to your backend for installs, conversions, leads, opens, referrals, clicks and link changes, how deliveries are logged and retried, and how to verify them in Node, Python and PHP.
DecTog POSTs events to your own backend: every install verdict, conversions and leads, app opens, referrals, link changes and, if you ask, every tap. Add webhooks in [Dashboard: Apps](/dashboard/apps/), under an app’s **Webhooks**. A webhook receives either that app’s events or every app’s, and the events you tick. Deleting an app deletes the webhooks limited to it. Every delivery is logged for 30 days and retried for about a day when your endpoint does not answer 2xx.
## Adding a webhook
[Section titled “Adding a webhook”](#adding-a-webhook)
**Add webhook** asks for the URL (http or https), whether it takes this app’s events or every app’s, and the events (all but `click.recorded` are ticked at first). The response shows the signing secret, `whsec_` and 64 hex characters, **once**: copy it then. **Rotate secret** replaces it and shows the new one once; the old one stops working at once. The server keeps the secret encrypted and never returns it again.
Which app an event concerns: for installs, the credited or routed link’s app, else the install’s own app (the one whose Android package or iOS bundle ID equals the `package_name` the SDK sends), else the oldest app; for events, opens, referrals, clicks and links, the link’s app, else the oldest app.
## The request
[Section titled “The request”](#the-request)
Every delivery is a POST of the same envelope:
POST to your endpoint
```json
{
"event_id": "evt_5c1f0a9b2d7e4c3a8b6f1e20",
"event": "app.installed",
"created_at": "2026-10-12T09:15:02.000Z",
"attempt": 1,
"data": { … }
}
```
| Header | Value |
| -------------------- | ------------------------------------------------------------------------------------------------------ |
| `content-type` | `application/json` |
| `x-dectog-event` | The event name, as in the body. |
| `x-dectog-event-id` | The `event_id`, as in the body. |
| `x-dectog-signature` | `sha256=` where hex is HMAC-SHA256 of the **raw request body** keyed with your secret, lowercase. |
| `x-dectog-timestamp` | Unix seconds when the request was built. |
`event_id` is the same on every attempt and on a manual resend: deduplicate on it. `attempt` counts from 1. The signature covers the body bytes only. Verify against the raw bytes exactly as received, before any JSON parsing or re-serialization.
## Events
[Section titled “Events”](#events)
| `event` | Sent when | `data` |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app.installed` | A first-launch report (`POST /api/v1/app-installs`) produced a verdict. Organic installs fire too. Replays (`"replayed": true`) do not. | the install, [below](#the-install-events) |
| `app.install_updated` | A late claim upgraded the verdict (`"claim": "upgraded"`), or Apple Search Ads resolved with `attribution: true`. | the same |
| `event.conversion`, `event.lead` | The SDK’s event batch, or [`dectog.js`](/sdk/web/) on your website, stored a conversion or a lead (the automatic signup lead included); duplicates are not sent again. `source` is `web` for the website’s, `store` for a store notification’s, `null` for the app’s. | `{username, event: {id, type, event_name, revenue, currency, revenue_base, transaction_type, transaction_id, original_transaction_id, product_id, store, source, customer_id, install_id, slug, open_slug, client_timestamp, metadata, created_at}}` |
| `app.opened` | A link opened the installed app (`POST /api/v1/app-opens`). | `{username, open: {id, slug, click_id, install_id, device_id, app_version, os, created_at}}` |
| `referral.converted` | A referred user signed up. | `{username, referral: {…}}`, see [Referral programs](/guides/referral-programs/#4-reward-from-the-webhook) |
| `click.recorded` | Every tap and every in-app open the SDK reports. Not ticked by default: one delivery per tap. | `{username, click: {id, slug, link_kind, source, device_type, os, browser, country, lang, referrer, ip_hash, utm_source, utm_medium, utm_campaign, utm_term, utm_content, link_params, clicked_at}}`; never the raw IP, the user agent or a device id |
| `link.created` | A permanent link was created: the API, the dashboard, a bulk create or import, an upsert, a referral link. | `{username, link}` with the [Link object](/api/links/#the-link-object) |
| `link.updated` | A permanent link changed, including pause, resume, archive, an expiry and a restore. | the same |
| `link.deleted` | A permanent link was deleted. | the same, plus `permanent` (`true` when its history went too) |
## The install events
[Section titled “The install events”](#the-install-events)
The `data` of `app.installed` and `app.install_updated`:
data of app.installed
```json
{
"install_id": "3d2b6c1e-8f0a-4b7c-9d2e-5a6b7c8d9e0f",
"matched": true,
"type": "non-organic",
"match_source": "referrer",
"referrer_source": "google_play",
"match_confidence": "guaranteed",
"install_state": "install",
"is_reinstall": false,
"fraud_flags": [],
"username": "acme",
"slug": "spring-sale",
"link": {
"slug": "spring-sale",
"target": "https://shop.example.com/sale",
"url": "https://acme.dectog.com/spring-sale",
"created_at": "2026-10-01T09:00:00.000Z"
},
"device": {
"device_id": "4b1d8c3e9f2a",
"device_model": "pixel 8",
"device_manufacturer": "Google",
"os": "android",
"os_version": "15",
"connectivity_type": "wifi",
"carrier_mcc": "262",
"carrier_mnc": "01"
},
"ip_hash": "9c1185a5c5e9fc54612808977ee8f548b2258d31f3f7a2b5a4e2f7c3d1b0a9e8",
"clicked_at": "2026-10-12T08:59:12.000Z",
"click_params": { "deep_link": "sale/42", "label": null, "campaign": "spring", "adgroup": null, "creative": null, "tracker": null },
"deep_link": "sale/42",
"apple_search_ads": null,
"processed_at": "2026-10-12T09:03:40.000Z"
}
```
| Field | Type | Meaning |
| ------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `install_id` | string | The install row’s id. |
| `matched` | boolean | Credited to a click. |
| `type` | string | `non-organic` when matched, else `organic`. |
| `match_source` | string or null | `referrer`, `clipboard`, `fingerprint`, or `null`. |
| `referrer_source` | string or null | `google_play`, `huawei`, `samsung`, `xiaomi`, `vivo`, `meta`, or `null`. |
| `match_confidence` | string or null | `guaranteed`, `high`, `medium`, `low`, or `null`. |
| `install_state` | string or null | `install`, `reinstall`, `update`, or `null`. |
| `is_reinstall` | boolean | |
| `fraud_flags` | string array | Rule names from [Clicks and installs](/api/clicks-and-installs/); empty when clean. |
| `username` | string | Your username. |
| `slug` | string or null | The matched link’s slug; `null` whenever `matched` is false, including a strict-mode install that was zeroed (its `fraud_flags`, `referrer_source` and `match_confidence` still show what was found). |
| `link` | object or null | `slug`, `target`, `url`, `created_at` of the matched **permanent** link. `null` for organic installs, temporary-link matches and strict-mode zeroed installs. There is no `metadata` here, unlike the install verdict. |
| `device` | object | `device_id`, `device_model`, `device_manufacturer`, `os`, `os_version`, `connectivity_type`, `carrier_mcc`, `carrier_mnc`; each `null` when the SDK did not send it. |
| `ip_hash` | string or null | The salted SHA-256 of the install request’s IP (IPv6 cut to /64). |
| `clicked_at` | string or null | The matched click’s time; `null` when `matched` is false. |
| `click_params` | object or null | `deep_link`, `label`, `campaign`, `adgroup`, `creative`, `tracker` from the matched click; `null` when `matched` is false or when the click carried none. |
| `deep_link` | string or null | Mirrors `click_params.deep_link`. |
| `apple_search_ads` | object or null | Same object as on the verdict: `null` until a token was sent, then `status`, `attribution`, `resolved_at` and, once Apple answered, the campaign record. An `app.install_updated` triggered by Apple has `"status": "resolved"`. |
| `processed_at` | string | When the payload was built. |
The payload has no `routing` object and no `install_attempt_id`.
## Delivery and retries
[Section titled “Delivery and retries”](#delivery-and-retries)
* The first attempt is sent right after the work that caused it; it never delays the response to the app or the API call. A request that produces more than 25 deliveries (a large event batch or bulk create) sends the first 25 at once and the rest with the next hourly run.
* A delivery succeeds on any 2xx answer. A non-2xx answer, a network error or no answer within 10 seconds is a failure, logged with the status and the first 500 characters of the response.
* Failures are retried by the hourly job (at :07 each hour) 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours after the previous attempt, so the shortest gaps can stretch to the next hour. After the sixth attempt the delivery is marked failed.
* Each webhook counts its failed attempts in a row, across deliveries. The account owner is emailed at 5, 10 and 15; at 20 the webhook is turned off and stops receiving events. Any successful delivery resets the count. **Turn on** resets it too, and the retries still pending go out with the next hourly run.
* Deliveries are not ordered. An `app.install_updated` can arrive before the `app.installed` of the same install when the first delivery was retried.
* Changes to a webhook can take up to 30 seconds to reach every server.
## The delivery log
[Section titled “The delivery log”](#the-delivery-log)
**Deliveries** on a webhook lists its last 20 deliveries of the past 30 days: the time, the event, **Delivered** with the status, **Retry** with the time of the next attempt, or **Failed** with the error, the attempt number and the start of the response. **Resend** sends one again now, with the same `event_id` and the next `attempt` number, whatever its state.
## Test event
[Section titled “Test event”](#test-event)
**Send test** delivers a `webhook.test` event now and logs it like any other delivery, with `data` `{"username": "acme", "test": true, "message": "A test delivery from the DecTog dashboard"}`. A failed test is neither retried nor counted toward the failure thresholds.
## Dashboard API
[Section titled “Dashboard API”](#dashboard-api)
The dashboard uses these session routes; see [Admin API](/api/admin-api/#webhooks).
| Route | Purpose |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/admin/webhooks` | `{data: [Webhook], events, default_events}`; a Webhook is `{id, app_id, url, events, active, consecutive_failures, disabled_at, disabled_reason, created_at, updated_at}` |
| `POST /api/admin/webhooks` | `{url, app_id?, events?}` → `201` Webhook plus `secret` |
| `PUT /api/admin/webhooks/:id` | any of `url`, `app_id`, `events`, `active` |
| `DELETE /api/admin/webhooks/:id` | deletes the webhook and its log |
| `POST /api/admin/webhooks/:id/rotate-secret` | Webhook plus the new `secret` |
| `POST /api/admin/webhooks/:id/test` | the logged `webhook.test` delivery |
| `GET /api/admin/webhooks/:id/deliveries?limit=` | `{data: [Delivery]}`, newest first, 1 to 200 |
| `POST /api/admin/webhook-deliveries/:id/resend` | the delivery after one more attempt |
## Verifying the signature
[Section titled “Verifying the signature”](#verifying-the-signature)
All three snippets compute `sha256=` + HMAC-SHA256(secret, raw body) and compare it in constant time with the `x-dectog-signature` header. They produce identical values for identical input.
Node (Express)
```js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.DECTOG_WEBHOOK_SECRET;
function verifyDectogSignature(rawBody, signatureHeader) {
if (typeof signatureHeader !== "string" || !signatureHeader.startsWith("sha256=")) return false;
const expected = "sha256=" + createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
// Keep the raw bytes: express.json() would re-serialize and break the check.
app.post("/dectog/events", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyDectogSignature(req.body, req.get("x-dectog-signature"))) {
return res.status(401).end();
}
const delivery = JSON.parse(req.body.toString("utf8"));
res.status(204).end(); // acknowledge first
// dedupe on delivery.event_id, then handle delivery.event and delivery.data
});
```
Python (Flask)
```python
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["DECTOG_WEBHOOK_SECRET"].encode()
def verify_dectog_signature(raw_body: bytes, signature_header: str | None) -> bool:
if not signature_header or not signature_header.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
@app.post("/dectog/events")
def dectog_events():
if not verify_dectog_signature(request.get_data(), request.headers.get("x-dectog-signature")):
abort(401)
delivery = request.get_json(force=True)
# dedupe on delivery["event_id"], then handle delivery["event"] and delivery["data"]
return "", 204
```
PHP
```php
How the worker serves the Apple and Android association files, robots.txt, the sitemap and blog post previews, and which host and path reaches which handler.
The DecTog worker answers every host in the zone: the site host (`dectog.com`, the apex, `SITE_URL`), the dashboard host (`app.dectog.com`, `APP_HOST`) and every account host (`.dectog.com`). This page lists what each host and path does. Self-hosted deployments substitute their own `ROOT_DOMAIN`, `SITE_URL`, `APP_HOST`, `WEB_URL` and `PUBLIC_BASE_URL`; see [Configuration reference](/self-hosting/configuration-reference/).
## Association files
[Section titled “Association files”](#association-files)
Universal Links and App Links need two files on the account host. The worker generates both from the apps registered in [Dashboard: Apps](/dashboard/apps/); nothing is uploaded. Setup on the app side is in [Platform setup](/sdk/platform-setup/).
| Path | Built from |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/.well-known/apple-app-site-association` | Every app of the account that has both `ios_team_id` and `ios_bundle_id`. |
| `/.well-known/assetlinks.json` | Every app of the account that has `android_package`. Fingerprints come from `android_sha256_fingerprints` and default to `[]`, which never verifies. |
### Which account the file is for
[Section titled “Which account the file is for”](#which-account-the-file-is-for)
1. A `?u=` query parameter always wins. This is how the dashboard’s Verify buttons fetch the file on the app host, and the only form that works in path mode or on `localhost`.
2. Otherwise, when `ROOT_DOMAIN` does not start with `localhost`, the first label of the Host header: `acme.dectog.com` serves the account `acme`.
3. Otherwise: `400 {"error": {"code": "invalid_request", "message": "Subdomain not found in host"}}`.
An unknown username, or a known one with no qualifying app, answers `404 {"error": {"code": "not_found", "message": "Configuration not found"}}`.
### Response
[Section titled “Response”](#response)
Both files are served with `content-type: application/json; charset=utf-8` and three extra headers: `x-hostname`, the request’s Host header, `x-subdomain`, the resolved username (or the first host label when none was resolved), and `access-control-allow-origin: *`, so the dashboard’s setup checklist can read them from your live host.
https\://acme.dectog.com/.well-known/apple-app-site-association
```json
{
"applinks": {
"apps": [],
"details": [
{ "appIDs": ["8H7C9JL2K3.com.example.app"], "components": [{ "/": "/*" }] }
]
},
"webcredentials": { "apps": ["8H7C9JL2K3.com.example.app"] }
}
```
One `details` entry per qualifying app; `webcredentials.apps` lists the same app ids, so password AutoFill can be associated too. Every path on the host is claimed (`/*`).
https\://acme.dectog.com/.well-known/assetlinks.json
```json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AA:BB:CC:…"]
}
}
]
```
One statement per qualifying app. Include the Play App Signing certificate’s fingerprint, not only your upload key’s, or verification fails on devices that installed from Play.
Check what a host serves
```bash
curl -si https://acme.dectog.com/.well-known/apple-app-site-association
curl -s 'https://app.dectog.com/.well-known/assetlinks.json?u=acme'
```
## robots.txt and sitemap.xml
[Section titled “robots.txt and sitemap.xml”](#robotstxt-and-sitemapxml)
Both are generated by the worker on every host because the blog lives in the database, not in the static export.
`GET /robots.txt` (`text/plain`):
```text
User-agent: *
Allow: /
Disallow: /dashboard
Disallow: /api
Sitemap: https://dectog.com/sitemap.xml
```
`GET /sitemap.xml` (`application/xml`) lists `SITE_URL` plus `/`, `/about`, `/blog` and `/contact`, then `/blog/` for every published post, newest first, with `` set to the post’s `updated_at` date (`YYYY-MM-DD`). Drafts are never listed. The origin is `SITE_URL` (`https://dectog.com` on the hosted service), the host that serves those pages; the dashboard host sends them there with a permanent redirect.
## Blog post pages
[Section titled “Blog post pages”](#blog-post-pages)
The static export has no page per post. `/blog/` on the site host is answered with the export’s article page, `/blog/article`, which loads the post in the browser; `/blog/` is the blog itself. The article page’s head has no title, description or share tags of its own. Link unfurlers never run the page’s script, so for a published post the worker adds them to the head before sending the page:
| Tag | Value |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `` | ` — DecTog Blog` |
| `description`, `og:description`, `twitter:description` | The post’s excerpt. Left out when the excerpt is empty. |
| `og:title`, `twitter:title` | The post’s title. |
| `og:url` | `SITE_URL` plus `/blog/`, the address the sitemap lists. |
| `og:site_name`, `og:type` | `DecTog`, `article` |
| `og:image`, `twitter:image` | The post’s featured image, else the site card `SITE_URL/media/og-card.png` with its `og:image:width` (1200), `og:image:height` (630) and `og:image:alt`. |
| `twitter:card` | `summary_large_image` |
The head also gets a canonical link to the same `og:url` address, and one BlogPosting JSON-LD block with the headline, the excerpt as description (when there is one), the address, the image, `datePublished`, `dateModified` (the post’s `updated_at`) and DecTog as author and publisher.
Each added tag carries a `data-post-head` attribute; the page removes them when the visitor moves on to another page of the app. The page is sent without the export’s `ETag`.
Search and AI crawlers that run no script would find no article in the page, so the worker also writes the post’s title as an ``, followed by its body, into the page’s loading state. The body keeps the editor’s markup (headings, paragraphs, lists, links, images, tables, code) and loses scripts, styles, frames, embedded objects, media, SVG, forms, every attribute outside a short list (so every event handler), and any `href` or `src` that is not `http`, `https`, `mailto` or relative. Once the post has loaded in the browser, the page shows it in place of the loading state.
Drafts and unknown slugs get the article page unchanged, with status `404`, so search engines drop the address. The page still loads, fails to find the post, shows “Article Not Found” and titles itself `Article Not Found — DecTog Blog`. A HEAD request gets the status a GET would: `200` with the export’s headers for a published post, `404` otherwise. A path under `/blog/` that cannot be a slug, such as `/blog/__next._tree.txt`, one of the files the browser’s router fetches, is served from the static export as it is, or answers `404` when the export has no such file.
### The blog index
[Section titled “The blog index”](#the-blog-index)
The exported `/blog` page has its own title and share tags but loads the list of posts in the browser. On a GET, the worker writes the published posts into the page’s loading state, newest first and at most 50: each post’s title as a link to `/blog/`, its excerpt when it has one, and its publication date (`Oct 11, 2026`, in UTC). The page is then sent without the export’s `ETag`. With nothing published, and for HEAD, the page is served as exported.
## Host and path routing
[Section titled “Host and path routing”](#host-and-path-routing)
Some paths belong to the worker on every host: anything under `/api/`, `/r/`, `/.well-known/` and `/_/`, plus exactly `/healthz`, `/readyz`, `/openapi.json`, `/robots.txt` and `/sitemap.xml`. `/mcp` and `/dectog.js` belong to the worker on the site and dashboard hosts; on an account’s link host they are ordinary slugs. Everything else depends on the host. The port is ignored when matching hosts.
The site host and the dashboard host both serve the dashboard’s static export (its `/_next`, `/media` and `/fonts` files are shared), each owning some of its pages: the site pages (`/`, `/about`, `/blog`, `/blog/`, `/contact`) belong to the site host, the dashboard pages (`/login`, `/dashboard`, `/invite`, `/reset-password`) to the dashboard host. A page asked of the other host answers `301` to the same path and query string on its own host. When `SITE_URL` and `WEB_URL` name the same host, that host serves every page and nothing redirects.
| Host | Path | Result |
| ------------------------------------------------------ | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| any | `/api/…` | The REST and admin APIs. |
| any | `/r//` | The redirect engine (path-mode link URL). |
| any except `APP_HOST` | `/?user=/` | The redirect engine (query-mode link URL). |
| any | `/.well-known/…` | The association files above. |
| any | `/_/collect` | The link pages’ beacon (POST): the screen profile and device settings, and which button the visitor took. |
| any | `/healthz`, `/readyz`, `/robots.txt`, `/sitemap.xml` | See this page. |
| any | `/openapi.json` | The OpenAPI description of every route. |
| `app.dectog.com`, `dectog.com` | `/mcp` | The [MCP server](/guides/automation-and-agents/#mcp-server). |
| `app.dectog.com`, `dectog.com` | `/dectog.js` | The [web pixel](/sdk/web/). |
| `dectog.com` | `/`, `/about`, `/contact`, `/SKILL.md`, `/llms.txt`, the export’s files | The static export: the landing page, the site pages and the agent docs, so `curl https://dectog.com/SKILL.md` works without a redirect. |
| `dectog.com` | `/blog`, `/blog/` | The export’s blog pages with the worker’s additions; an unknown slug answers `404` (see [Blog post pages](#blog-post-pages)). |
| `dectog.com` | `/login`, `/dashboard…`, `/invite`, `/reset-password` | `301` to `WEB_URL` plus the path and the query string: `https://dectog.com/login?tab=signup` goes to `https://app.dectog.com/login?tab=signup`. |
| `app.dectog.com` | `/` | `302` to `/dashboard` with a valid session cookie, else to `/login`, with `cache-control: private, no-store`. |
| `app.dectog.com` | `/about`, `/contact`, `/blog`, `/blog/…` | `301` to `SITE_URL` plus the path and the query string: `https://app.dectog.com/contact?ref=x` goes to `https://dectog.com/contact?ref=x`. |
| `app.dectog.com` | any other path | The dashboard’s static export. `/login` and `/login/` with a valid session cookie answer `302 /dashboard` with `cache-control: private, no-store`. |
| the apex without an assets binding (local development) | any path but `/` | `301` to `SITE_URL` plus the path and the query string; `/` answers `302` to `SITE_URL`. |
| `.dectog.com` | `/` | Known username: `302` to `WEB_URL`. Unknown username: `302` to `UNKNOWN_HOST_REDIRECT` (`https://dectog.com/` on the hosted service), or `404 {"error": {"code": "not_found", "message": "Not found"}}` when it is unset. `www` is a reserved username, so `www.dectog.com` follows the unknown-host rule. |
| `.dectog.com` | `/` | The redirect engine. An unknown username here also follows `UNKNOWN_HOST_REDIRECT`; an unknown slug gets the 404 page and an expired temporary link the 410 page. See [How a tap is routed](/links/how-a-tap-is-routed/). |
| `.dectog.com` | two or more path segments | `404 {"error": {"code": "not_found", "message": "Not found"}}`. |
In the hosted deployment `WEB_URL` and `PUBLIC_BASE_URL` are both `https://app.dectog.com` and `SITE_URL` is `https://dectog.com`, so account roots send visitors to the dashboard host and unknown hosts to the site.
### HEAD requests
[Section titled “HEAD requests”](#head-requests)
A HEAD request runs the same handler as GET and the response is returned without a body, with the same status and headers. The redirect engine is the exception: a HEAD to a link URL is treated like a bot and answers the social preview page’s headers without recording a click. Point uptime checks at `/healthz` or `/readyz`, not at link URLs.
## Health check
[Section titled “Health check”](#health-check)
`GET /healthz` answers on every host:
```json
{ "ok": true, "service": "dectog", "version": "v1", "host": "app.dectog.com" }
```
`host` echoes the request’s Host header (`null` when absent). The route needs no authentication and is not rate limited.
`GET /readyz` is the readiness check. It runs `SELECT 1` against D1 and compares wrangler’s `d1_migrations` table with the migrations bundled into this build:
```json
{ "ok": true, "database": true, "migrations": { "expected": 62, "applied": 62, "missing": [] } }
```
It answers `503` with `ok: false` when D1 does not answer or a migration is missing, and lists the missing file names. It sends `cache-control: no-store`. Use it in deploy smoke tests: `/healthz` passes even with a broken database binding.
# Contributing
> Repository layout of the server, the Flutter SDK and these docs, how to run the tests, the migration and data-layer rules, how to propose changes, and the three licenses.
DecTog is four repositories under [github.com/DecTog](https://github.com/DecTog):
| Repository | Contents | License |
| ---------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------- |
| `DecTog/dectog` | The server: Cloudflare Worker, D1 database, Next.js dashboard and marketing site | AGPL-3.0 |
| `DecTog/flutter_dectog` | The Flutter SDK, the `flutter_dectog` package (not yet published on pub.dev) | MIT |
| `DecTog/react-native-dectog` | The React Native and Expo SDK, the `react-native-dectog` package (not yet published on npm) | MIT |
| `DecTog/docs` | This site | Content CC BY 4.0, site code MIT |
## Repository layout
[Section titled “Repository layout”](#repository-layout)
### `dectog` (server)
[Section titled “dectog (server)”](#dectog-server)
| Path | What it is |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/worker/src/` | The Worker: `index.ts` routes hosts and paths; `redirect.ts` is the link engine; `api_links.ts`, `api_clicks.ts`, `api_installs.ts`, `api_events.ts` are the SDK API; `attribution.ts`, `fraud.ts`, `asa.ts` decide verdicts; `api_admin.ts`, `api_auth.ts`, `api_google.ts`, `api_customers.ts`, `api_attribution_admin.ts`, `api_blog.ts` serve the dashboard; `auth.ts`, `ratelimit.ts`, `webhook.ts`, `wellknown.ts`, `ogpreview.ts`, `collect.ts`, `seed.ts`, `bootstrap.ts` |
| `apps/worker/src/db/` | The typed data layer: `tables.ts` (column registry), `write.ts` (`insertStmt`, `updateById`), `links.ts`, `tempLinks.ts`, `users.ts` (the shared lookups) |
| `apps/worker/migrations/` | `0001_schema.sql` (the whole schema), then one numbered file per later change |
| `apps/worker/test/` | Vitest suites on the Workers pool with an isolated D1 |
| `apps/worker/wrangler.jsonc` | Dev defaults and the `production` environment |
| `wrangler.jsonc`, `package.json` (root) | The Deploy to Cloudflare button’s deployment on workers.dev and its build and deploy commands. See [One click on workers.dev](/self-hosting/deploy/#one-click-on-workersdev) |
| `apps/web/src/app/` | The Next.js app: `(site)` marketing pages, `login`, `reset-password`, `dashboard/**` |
| `apps/web/public/SKILL.md`, `llms.txt` | The agent guide and index, served at `https://dectog.com/SKILL.md` and `/llms.txt` |
| `docs/API_CONTRACT.md` | The SDK-to-worker contract, route by route |
| `deploy/terraform/` | A Terraform module for the Cloudflare resources of a self-hosted deployment: the zone, DNS records, the D1 database, the optional exports bucket and custom-domain fallback origin. See [Deploy to Cloudflare](/self-hosting/deploy/#infrastructure-as-code-with-terraform) |
### `flutter_dectog` (SDK)
[Section titled “flutter_dectog (SDK)”](#flutter_dectog-sdk)
| Path | What it is |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lib/flutter_dectog.dart` | The public entry point |
| `lib/src/dectog_impl.dart` | `FlutterDectog`: initialization, link handling, install registration, claims, events, link CRUD |
| `lib/src/models/` | `DectogConfig`, `DectogLink`, `DectogTempLink`, `DectogLinkResolution`, `DectogLinkClick`, `DectogAppInstallRequest`, `DectogAppInstallResponse`, `DectogClickParams`, `DectogError` |
| `lib/src/services/` | Device identity and hardware, the install referrer service, the install state store (the `.dectog_install.json` sentinel), the event queue, request signing, the retry policy, the sensitive-URL filter, the link deduper |
| `lib/src/widgets/dectog_paste_button.dart` | `DectogPasteButton` |
| `android/src/main/kotlin/app/dectog/core/` | The framework-free Android reads every DecTog SDK shares as source: `DectogDevice.kt` (device id, carrier, package and launch info, Meta referrer, App Links state), `InstallReferrers.kt` (Play, Huawei, Samsung, Xiaomi and vivo readers), `Pasteboard.kt` |
| `android/src/main/kotlin/app/dectog/flutter_dectog/` | `DectogIdentifiersPlugin.kt`, which maps the method channel onto `app.dectog.core` |
| `ios/flutter_dectog/` | The Swift package. Framework-free, shared as source: `DectogDevice.swift`, `DectogInstallDates.swift`, `DectogKeychain.swift`, `DectogPasteTargetView.swift`; Flutter glue: `DectogIdentifiersPlugin.swift`, `DectogPasteControl.swift`. `ios/flutter_dectog.podspec` for CocoaPods hosts |
| `example/` | Trails, the example app: shared links, invites and referrals, onboarding, `identify`, a demo purchase, screen views. Reads `env/dev.json` for `DECTOG_API_KEY`, `DECTOG_BASE_URL`, `DECTOG_LINK_ROOT_DOMAIN`; `example/site/` is its store-badges page |
| `test/` | Unit tests for the models, the referrer service, the state store, the event queue, signing, the retry policy, the URL parser and the paste button |
| `doc/api-reference.md`, `llms.txt` | The API reference this site syncs, and the agent index generated from it (`dart run tool/gen_llms.dart`) |
### `react-native-dectog` (SDK)
[Section titled “react-native-dectog (SDK)”](#react-native-dectog-sdk)
A port of `flutter_dectog` on the same contract and fixtures; [Porting the SDK](/contributing/porting-the-sdk/) explains what it shares and what it reimplements.
| Path | What it is |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/Dectog.ts` | `DectogSdk` and the `Dectog` singleton: initialization, link handling, install registration, claims, events, link CRUD (the port of `lib/src/dectog_impl.dart`) |
| `src/internal/` | The services: install state, event queue, request signing with its own SHA-256 and HMAC, the retry policy, the URL parser, the link filter and deduper, the referrer and pasteboard gates |
| `src/models/`, `src/config.ts`, `src/errors.ts` | The public types, `DectogConfig` and `DectogError` |
| `src/navigation.ts`, `src/PasteButton.tsx` | `trackNavigation` for React Navigation and Expo Router, and `DectogPasteButton` |
| `ios/Shared/`, `android/src/main/java/app/dectog/core/` | Copies of the shared native reads; `tool/sync_shared.sh --check` fails on any difference from `flutter_dectog` |
| `ios/DectogModule.swift`, `android/.../reactnative/DectogModule.kt` | The Expo module: the bridge onto the shared reads |
| `plugin/src/` | The config plugin: associated domains, URL types, intent filters; `app.plugin.js` loads it |
| `test/` | Jest suites against a fake native layer and a recording network; `test/fixtures/` are the shared wire fixtures and URL cases |
| `example/` | An Expo app with the plugin configured; `expo prebuild` generates its native projects |
| `doc/api-reference.md`, `llms.txt` | The API reference this site syncs, and the agent index (`npm run llms`) |
### `docs` (this site)
[Section titled “docs (this site)”](#docs-this-site)
| Path | What it is |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `src/content/docs//.md` | One page per file; the sidebar groups are fixed in `astro.config.mjs` and pages are ordered by `sidebar.order` |
| `src/components/Footer.astro` | The footer with the version note (the SDK versions and the latest migration) and the license split |
| `wrangler.jsonc` | The Worker that serves `dist/` on the route `docs.dectog.com/*` |
| `.github/workflows/deploy.yml` | Builds on every push and pull request; deploys on push to `main` |
## Running the checks
[Section titled “Running the checks”](#running-the-checks)
```bash
# Server
cd apps/worker && npm ci && npm test # Vitest on the Workers pool (on macOS: npm run test:split)
cd apps/worker && npm run typecheck # tsc --noEmit
cd apps/web && npm ci && npm run typecheck
# SDKs
cd flutter_dectog && flutter pub get && flutter analyze && flutter test
cd react-native-dectog && npm ci && npm test && npm run typecheck && npm run sync:check
# Docs
cd docs && npm ci && npm run build # fails on broken internal links
```
The worker suite covers the attribution ladder end to end (referrer, clipboard, Meta, scored fingerprint and confidence, late claims, idempotent replays, one install per click, synthesized clicks), the fraud rules and strict mode, Apple Search Ads against a stand-in API, OEM store routing and the tap-to-store page, request signing and rate limits, the events pipeline and customers, link and app CRUD, the redirect engine, retention and the schema registry.
To run the server locally with the demo account, follow [Local quick start](/self-hosting/local-quick-start/). To test an SDK change against it, run the worker with `npm run dev:android` and the example app with `flutter run --dart-define-from-file=env/dev.json`. See [Testing](/sdk/testing/).
## Migration rules
[Section titled “Migration rules”](#migration-rules)
* `apps/worker/migrations/` is append-only. A migration that has been applied to a shared or remote database is never edited or deleted; add a new numbered file.
* Files apply in name order. `0001_schema.sql` is the starting schema; each change after it is the next numbered file.
* A migration that adds or removes a column must also change the row type in `src/types.ts` and the column registry in `src/db/tables.ts`. `tsc` enforces the type-registry pair and `test/schema.test.ts` compares the registry with `PRAGMA table_info` on the migrated test database.
* Deploys run `wrangler d1 migrations apply DB --remote --env production` before `wrangler deploy --env production`. Plain `npm run deploy` uses the dev configuration. See [Database and migrations](/self-hosting/database-and-migrations/).
## Data-layer rules
[Section titled “Data-layer rules”](#data-layer-rules)
The worker talks to D1 with raw, parameterized SQL and no ORM. The rules:
1. Every value is bound with `?`. Request input never reaches SQL text.
2. Reads carry a row type from `src/types.ts` (`.first()`, `.all()`) or an inline shape for aggregates. No `any`.
3. The link, temp-link and user lookups live once, in `src/db/links.ts`, `src/db/tempLinks.ts` and `src/db/users.ts`. Do not paste that SQL again.
4. Writes go through `src/db/write.ts`: `insertStmt(db, table, row)` requires the full row type, so a new column turns every insert site into a compile error until it supplies a value; `updateById(db, table, id, patch)` takes a typed partial. Expression updates such as `click_count = click_count + 1`, the analytics, attribution, rate-limit and cron SQL stay raw.
5. Time cutoffs are computed in JavaScript (`isoDaysAgo` in `src/util.ts`) and bound as ISO strings. Never compare a stored timestamp with SQLite’s `datetime('now', …)`: it emits `YYYY-MM-DD HH:MM:SS`, and because `T` sorts after a space, stored `…T…Z` values compare as newer than they are.
## Design decisions to keep
[Section titled “Design decisions to keep”](#design-decisions-to-keep)
* No billing. There is no billing or subscription code anywhere, and the docs do not describe any.
* API keys are stored as a SHA-256 hash plus a prefix. The account key is shown once and kept no other way; SDK and site keys also keep an AES-GCM encrypted copy (keyed from `JWT_SECRET`) so Settings can show them again. Every SDK call is also HMAC-signed with the key.
* Attribution: deterministic signals first (store referrers, Meta, pasteboard), then a scored, tenant-scoped fingerprint; one install per click; 24-hour window for candidates with a device model or a screen profile to compare with the install and 2 hours otherwise; credit only at high confidence, routing at medium, nothing on a tie between different links. See [Attribution internals](/self-hosting/attribution-internals/).
* Privacy: hashed IPs (IPv6 at /64), raw rows deleted (or their personal columns blanked) on the retention schedule, ad identifiers in their own table and never joined to device data, and a vendor-scoped device id that survives a reinstall by default (`persistDeviceId` on iOS). See [Privacy and data](/self-hosting/privacy-and-data/).
* The name: DecTog is a coined word with no expansion. Prose writes DecTog, one word with a capital D and a capital T; code, package names and domains use `dectog`; identifiers keep their language’s casing (`FlutterDectog`, `DectogConfig`).
## Proposing a change
[Section titled “Proposing a change”](#proposing-a-change)
1. Open an issue on the repository the change belongs to. For behavior changes, describe the current behavior and the one you want before writing code.
2. Fork, branch, and keep the checks above green. A worker change that touches a route, a verdict field or a limit needs a test in `apps/worker/test/`; an SDK change needs one in `test/`.
3. Keep the prose in step with the code. The pages of this site, `docs/API_CONTRACT.md`, `apps/web/public/SKILL.md` and `llms.txt` all describe the same behavior; update the ones your change affects, and bump the version note in `src/components/Footer.astro` when the SDK version or the latest migration changes.
4. Never commit secrets. `.dev.vars`, `env/dev.json` and OAuth client secret files stay out of commits.
5. Open a pull request. The docs site builds every pull request; a merged change reaches docs.dectog.com with the next deploy.
Documentation fixes are the quickest contribution: every page has an “Edit page” link that opens the file on GitHub.
## Licenses
[Section titled “Licenses”](#licenses)
* The server (`DecTog/dectog`: the Worker, the web app and everything else in that repository) is licensed under the GNU AGPL v3. You can run it, modify it and self-host it. If you offer a modified version as a network service, you must publish your changes.
* The Flutter SDK (`DecTog/flutter_dectog`) is licensed under the MIT License, so the AGPL never reaches your app.
* These docs: the content under `src/content` is licensed under CC BY 4.0 (reuse it with attribution); the site code (configuration, components, styles) is MIT.
# Porting the SDK
> How a DecTog SDK is built on the shared contract, the shared fixtures and the shared native code, using the React Native SDK as the worked example.
DecTog has two app SDKs on one contract: `flutter_dectog`, the reference implementation, and `react-native-dectog`, a port of it. A new SDK (a native Swift or Kotlin one, say) follows the same route. Nothing below is a specification of its own: the server’s [API overview](/api/overview/) and its `docs/API_CONTRACT.md` are the contract, and the Flutter SDK’s behaviour is the reference.
## What a port shares, byte for byte
[Section titled “What a port shares, byte for byte”](#what-a-port-shares-byte-for-byte)
| What | Source | Why |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Golden wire fixtures | `dectog/apps/worker/test/fixtures/wire/` | Every v1 response the server sends, held to its shape by the server’s own test. Each SDK parses all of them in a test that fails when a new fixture appears unparsed. |
| Link-URL cases | `flutter_dectog/test/fixtures/url-cases.json` | Which URLs are DecTog links and which are not: subdomain, path and custom-domain forms, `dtg_l` scheme links, development forms, and the shapes that must be rejected. Each SDK runs every case. |
| Native reads | `flutter_dectog/android/src/main/kotlin/app/dectog/core/`, and `DectogDevice.swift`, `DectogKeychain.swift`, `DectogInstallDates.swift`, `DectogPasteTargetView.swift` | The store install referrers, the pasteboard, the paste control, the Keychain device id, the install dates. They import no framework, so an SDK maps its own bridge onto them instead of rewriting them (design decision R19: shared native code stays source, never an opaque binary). |
| The iOS privacy manifest | `flutter_dectog/ios/.../PrivacyInfo.xcprivacy` | The same reads, so the same declarations. |
`react-native-dectog` copies these with `tool/sync_shared.sh`, and its CI fails when a copy differs. A change to a native read lands in `flutter_dectog` first.
## What a port reimplements
[Section titled “What a port reimplements”](#what-a-port-reimplements)
The SDK logic itself, against the Flutter SDK’s code and tests: the install registration (one per install, a persisted `install_attempt_id`, the backup-excluded sentinel checked against the package’s first-install time, bounded retries with `Retry-After`, late claims, recovery when the server lost the install), link handling (the cold start once per process, relaunches from recents ignored, OAuth callbacks never reported), the durable event queue with `client_event_id`s, consent mode, request signing (`v1=HMAC-SHA256(key, ".")` over the exact bytes sent), and the server’s error envelope. `react-native-dectog` carries its own SHA-256 and HMAC, because Hermes has no WebCrypto, checked against the NIST and RFC 4231 vectors.
Each behaviour the Flutter SDK’s harness test (`test/sdk_harness_test.dart`) checks has a counterpart in the port’s (`test/sdk.test.ts` in React Native), run against a fake native layer and a recording network.
## Identity
[Section titled “Identity”](#identity)
A port sends `x-dectog-sdk: /` on every signed call (`react-native-dectog/1.0.0`); the server stores it on installs, events and clicks, the Overview lists the builds in use, and `MIN_SDK_VERSION` can name a minimum per SDK. It sends `app_platform` on the install (`flutter`, `react_native`).
## Where to start
[Section titled “Where to start”](#where-to-start)
Read `react-native-dectog`: `src/Dectog.ts` is the port of `flutter_dectog/lib/src/dectog_impl.dart`, `src/internal/` of `lib/src/services/`, and `ios/DectogModule.swift` and `android/.../DectogModule.kt` show how little a bridge needs once the native reads are shared. See [Contributing](/contributing/) for each repository’s layout and checks.
# Account and sign-in
> Creating an account, joining a team from an invite, signing in with a password or with Google, resetting a password, the owner role, and the account changes the dashboard does not offer.
Sign-in lives at . The page is titled **Dashboard Access** (“Sign in — or create an account — to manage your deep links”) and has two tabs, **Log in** and **Sign up**. `https://app.dectog.com/login?tab=signup` opens the Sign up tab directly. A browser that already holds a valid session is sent straight to the dashboard.
## Sign up
[Section titled “Sign up”](#sign-up)
| Field | Rules |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Email** | A valid address. Stored trimmed and lower-cased. |
| **Username** | Suggested from the part of your email before `@` until you edit it (with the domain’s first label added when that part is under 3 characters: `me@example.com` suggests `me-example`). 3 to 32 characters: lowercase letters, digits and hyphens, starting and ending with a letter or digit. Reserved names are refused: `app`, `www`, `api`, `links`, `dashboard`, `admin`, `mail`, `static`, `cdn`, `docs`, `status`, `help`, `support`, and any name starting with `t-`, the prefix of [test environments](/dashboard/settings/#test-environment). The hint reads “Your links live on {username}.{root domain}” (`dectog.com` on the hosted service): the username is your link subdomain and cannot be changed later. |
| **Password** | 8 to 72 characters with at least one lowercase letter, one uppercase letter and one digit. Hint: “At least 8 characters, with upper and lowercase letters and a number”. |
**Create Account** submits. Errors, verbatim from the server: `Please enter a valid email address`; the password rule that failed, such as `Password must include an uppercase letter`; `Username must be 3-32 lowercase letters, digits or hyphens, starting and ending alphanumeric`; `Username '{name}' is reserved`; `An account with this email or username already exists`; and `Signup is currently closed` when the deployment has closed signups, in which case the Sign up tab is hidden as well.
On success you are signed in and see **Account created — you are signed in.** with two keys: **SDK key, for your app** (listed again under Settings → [SDK keys](/dashboard/settings/#sdk-keys)) and **Account key, for servers and scripts**, which this screen shows once: “Copy it now: it is not shown again (Settings can replace it).” Then **Go to dashboard**.
A footer line reads “Your account gets its own link subdomain and API key.” Every account created through signup has the `user` role (see [Roles](#roles)).
## Log in
[Section titled “Log in”](#log-in)
| Field | Behavior |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Email**, **Password** | Checked locally first: `Please enter a valid email address`, `Password is required`. |
| **Remember me for 30 days** | Checked: the browser keeps the session cookie for 30 days. Unchecked: the cookie is deleted when the browser closes. |
| **Sign In** | Errors: `Invalid email or password` for a wrong pair, `An unexpected error occurred` for anything else. More than 10 attempts in a minute from one network show `Too many sign-in attempts. Wait a minute and try again.` until the minute ends. There is no per-account lockout. |
| **Forgot password?** | Opens the reset form described below. |
| **Sign in with Google** | Shown when the deployment has Google sign-in configured. |
The session is a cookie named `dectog_session` holding a signed token that expires 30 days after sign-in whatever the checkbox; the checkbox only decides whether the browser keeps the cookie across restarts. **Log out** (the icon in the sidebar footer) deletes the cookie and returns to the login page; sessions open in other browsers keep running until their token expires. Two things end every session at once, in every browser: [resetting the password](#forgot-and-reset-password) and [connecting Google](#connect-google).
Local development builds also show “Demo account: · demo1234” under the form; the hosted service does not.
## Sign in with Google
[Section titled “Sign in with Google”](#sign-in-with-google)
**Sign in with Google** (or **Sign up with Google** on the Sign up tab) sends you to Google’s account chooser and back, through a server-side OAuth authorization-code flow with PKCE. What happens next depends on the Google account and its verified email:
| Situation | Result |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The Google account is connected to a DecTog account. | You are signed in, whatever its email is now. |
| A DecTog account uses the same email and has no Google account connected. | Error: `A DecTog account already uses that email. Sign in with its password, then connect Google in Settings → Account.` Nothing is linked. |
| A DecTog account uses the same email but is connected to a different Google account. | Error: `That email is already linked to a different Google account.` |
| No account uses the email and signups are open. | One more step: **Signed in with Google as {email}. One last step:** with a **Choose a username** box that follows the signup rules. **Create Account** creates the account and shows the API key screen; **Cancel** abandons it. You have 15 minutes to finish, after which the page reports `Google sign-in expired — try again`. |
| No account uses the email and signups are closed. | Error: `No DecTog account uses that Google email, and signup is currently closed.` |
Other errors shown on return: `Google sign-in was cancelled.`; `Google sign-in timed out — please try again.` (the handshake must complete within 10 minutes); `Couldn't verify your Google account — please try again.`; `Google sign-in is not available right now.`. Username errors on the last step: `That username is taken` and `An account with this Google email already exists — sign in instead`.
An account created through Google has no usable password: the server stores a random one. To sign in with a password later, use **Forgot password?** to set one.
### Connect Google
[Section titled “Connect Google”](#connect-google)
A matching email is not enough to link an existing account: sign-up does not check that the address belongs to whoever signs up, so an account with your email might not be yours. To add Google sign-in to an account you have, sign in with its password, open Settings → **Account**, and select **Connect Google** next to **Sign in with Google** (shown when the deployment has Google sign-in configured). Choose the Google account; back in Settings, “Google is connected” confirms it, and the row reads **Connected**. Any Google account works, whatever its email. Connecting signs out every other session of the account; this browser stays signed in.
Errors shown in Settings: `That Google account already signs in to another DecTog account.`, `Your sign-in is already connected to a different Google account.`, `Connecting Google was cancelled.`, `Connecting Google timed out. Please try again.`, `Couldn't verify your Google account. Please try again.`. Connecting cannot be undone from the dashboard.
If an account with your email exists that you did not create, use **Forgot password?**: the reset link goes to your address, and the new password signs out every session of whoever made it.
## Forgot and reset password
[Section titled “Forgot and reset password”](#forgot-and-reset-password)
**Forgot password?** shows **Reset Password** (“Enter your email to receive a reset link”) with an **Email** box and **Send Reset Link**. The reply is “If that email exists, a reset link is on its way.”, whether or not an account exists. More than 5 requests a minute from one network, or 3 a minute for one address, show `Too many reset requests. Wait a minute and try again.`
The email, with the subject “Reset your DecTog password”, carries a link to `https://app.dectog.com/reset-password?token=…`. The token:
* is valid for 1 hour;
* can be used once; the server marks it used in the same step that changes the password, so a second submit fails;
* is cancelled by any newer request: only the most recent link works.
The reset page (“Choose a new password for your account”) has **New password** and **Confirm new password** boxes and an **Update Password** button. Errors: `This reset link is missing its token — open the link from your email.`, the password rules, `Passwords do not match`, `This reset link is invalid or has expired`, `This reset link has already been used`. Success: “Password updated — sign in with your new password.” The reset does not sign you in, and it ends every session of the account, in every browser.
## Teams
[Section titled “Teams”](#teams)
Several people can work in one account. Each person signs in as themselves, and the account’s owners invite others from Settings → [Team](/dashboard/settings/#team), each with a role:
| Role | Can |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Owner** | Everything: links, apps, webhooks, customers and exports, plus the keys, the team, the custom domain, the revenue currency, the retention window, the test environment, and erasing a device or a customer. |
| **Member** | Links, link templates, temporary links, apps, webhooks, customers, exports and the audit log. Sees the SDK keys but does not create or revoke them. |
| **Viewer** | Reads the numbers: Overview, Links, Apps and the parts of Settings without keys. No customers, exports, keys or audit log, and no one’s personal data: the events, installs and links a viewer reads leave out customer and device ids, the metadata an app sent, where a tap was (city, region, network) and a referral link’s customer, and webhook URLs show only their address (`https://hooks.example.com`), since a webhook’s path can work as a password. |
The person who owns an account is always its owner, and cannot be removed or demoted. Owners can make others owners too.
### Joining by invite
[Section titled “Joining by invite”](#joining-by-invite)
An invite email links to `{WEB_URL}/invite?token=…`. The link works for 7 days, once, and only for the invited address. The **Join a team** page names the account, your role and the address, then offers what fits:
| Situation | The page shows |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The address has no DecTog sign-in yet. | **Choose a password** (the [sign-up password rules](#sign-up)) and **Join {account}**, and **Join with Google** when the deployment has Google sign-in. No username: you sign in as that address and work in the account that invited you. |
| The address already signs in. | **Sign in to join** (the login page, with the address filled in), and **Sign in with Google**. Back on the invite, select **Join {account}**. |
| You are signed in with that address. | **Join {account}**. |
| You are signed in with another address. | “You are signed in as {address}. Sign out, then open this link again to join with {invited address}.” |
Joining with a password or with Google signs you in and opens the dashboard on the account you joined. **Join with Google** only accepts the Google account whose verified email is the invited address; another one returns to the invite with “That Google account is not {address}. Choose the Google account for {address}, or join with a password.” If the address already signs in with a password, it returns with “{address} already signs in with a password: sign in with it to join.”
Joining from an invite works even when the deployment has closed sign-ups: the invite is an owner letting you in.
#### No account of your own
[Section titled “No account of your own”](#no-account-of-your-own)
Someone who joins from an invite has no account of their own: no username, no link subdomain, no keys. They work only in the accounts they joined. The sidebar shows the **Account** menu with the account they are in, and an **Open your own account** link to `/dashboard/own-account`: choose a username (the [sign-up rules](#sign-up), suggested from your email), select **Open my account**, and copy the two keys it shows once, as after sign-up. **Go to {username}** opens the dashboard on the new account; the accounts you joined stay in the **Account** menu. Opening an account follows the deployment’s sign-up setting: it is refused while sign-ups are closed.
Signing up on the login page, with a password or with Google, always creates an account of your own; only joining from an invite skips it.
After joining, an **Account** menu at the top of the sidebar lists your own account (if you have one) and every account you belong to, each with your role there; choosing one reloads the dashboard on it, on its live data. **Leave** at the bottom of the Team card leaves the account shown; an owner has to invite you again. When an owner removes you, your next request lands on your own account, or on the next account you joined. Someone left in no account at all sees **No account yet**, with the same **Open my account** form and **Log out**; opening another invite link joins that account.
## Roles
[Section titled “Roles”](#roles)
Every account also has a platform role: `owner` or `user`. It is separate from the team roles above.
* **Users** see Overview, Links, Apps, Customers and Settings of the account chosen in the **Account** menu, as far as their team role there allows (a viewer has no Customers). There is no cross-account view.
* The **owner** additionally sees **Blog** and **Messages** in the sidebar; see [Blog and messages](/dashboard/blog-and-messages/). Non-owners who open `/dashboard/blog` or `/dashboard/messages` by URL are sent back to the Overview, and the API answers `403 Owner access required`.
The owner is the account created by the deployment’s bootstrap call, or by the local demo seed. When the role column was introduced, the oldest existing account became the owner. Signup and Google sign-in always create `user` accounts, and there is no screen or API call to change a role. On the hosted service, accounts created through signup are `user` accounts with the full dashboard; Blog and Messages belong to the site operator.
## What the dashboard does not offer
[Section titled “What the dashboard does not offer”](#what-the-dashboard-does-not-offer)
* **Renaming the username.** It is your link subdomain; there is no endpoint to change it.
* **Changing the email.**
* **Transferring an account.** Owners can add owners, but the account’s own login stays its owner.
* **Deleting the account.** Links can be deleted one by one, which removes their data; the account itself cannot. Contact the operator of the deployment.
* **Changing the password while signed in.** Use **Forgot password?** and the emailed link.
* **Ending other sessions.** A session ends when its token expires, 30 days after sign-in.
Self-hosting
Signups are open unless the worker’s `OPEN_SIGNUPS` variable is `"0"`. Google sign-in appears only when `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set, with `{WEB_URL}/api/auth/google/callback` registered as the redirect URI. Reset emails go through Resend with `RESEND_API_KEY`; without it, the reset link is printed to the worker’s log instead. The hint under the username box always says `.dectog.com`, even when your links live under another root domain. See [Configuration reference](/self-hosting/configuration-reference/) and, for creating the owner account, [Local quick start](/self-hosting/local-quick-start/).
# Apps
> Register iOS and Android apps, with every field, its validation, the status chips, the Auto-fill, Verify and Send test actions, and which app's settings the server actually applies.
The Apps screen, at , holds the identifiers DecTog needs to route taps and open your app. Its header reads “Register your iOS and Android apps. Their identifiers power store routing, deep links and the Universal Links / App Links files DecTog serves for you.”
An **Add app** button opens the editor. While the editor is closed, a status line shows “Saved ✓”, “Deleted ✓” or the last error.
Which app's settings apply
Most settings belong to the app a link is bound to: the link’s **App**, or your oldest app when a link has none. **Strict fraud mode** and the **Low-entropy attribution window** belong to the app an install concerns, and so does an install’s webhook event: the app of the credited or routed link; else the install’s own app, the one whose Android package or iOS bundle ID equals the `package_name` the SDK sends (on an account with one app, that app); else the oldest app. The app’s identifiers matter here: an install whose package name matches none of your apps falls back to the oldest app on an account with several.
## App cards
[Section titled “App cards”](#app-cards)
Each registered app is a card with two-letter initials, the name, the iOS bundle ID or Android package (or “No identifiers yet”), **Edit**, **Delete** and five status chips. A chip is green when its condition holds and gray otherwise.
| Chip | Green when | Hover text |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| **iOS links** | Both the iOS bundle ID and the Apple Team ID are set. | Universal Links: iOS bundle ID + Apple Team ID |
| **Android links** | The Android package and at least one SHA-256 fingerprint are set. | App Links: package + SHA-256 fingerprints |
| **Store routing** | An App Store URL or a Play Store URL is set. | App Store / Play Store URL |
| **Scheme** | Shows the scheme: `myapp://` when both platforms share one, `iOS a:// · Android b://` when they differ, or **No scheme**. | Custom URL scheme for deep links |
| **Fallback URL** | A fallback URL is set. | Web fallback for desktop and missing destinations |
The oldest app is the account’s default app. It serves links created with **Default app**, every temporary link, and links whose app was deleted.
## The editor
[Section titled “The editor”](#the-editor)
The editor is titled `Edit `, **Register your first app** or **Register an app**, with the subtitle “Only the name is required — fill in each platform as you set it up.” On wide screens a side menu jumps to the six sections below. The editor is always open while the account has no app.
### Are we live yet?
[Section titled “Are we live yet?”](#are-we-live-yet)
Editing a saved app opens with a checklist the server computes on every visit; nothing in it is stored.
| Step | Done when |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| App identifiers saved | An iOS bundle ID and Team ID, or an Android package and at least one SHA-256 fingerprint |
| Universal Links file passes Apple’s rules | The `apple-app-site-association` file served for your account lists this app’s `.`, covers every path, is JSON under 128 KB with no byte-order mark, and is served over https with `application/json` |
| App Links file passes Google’s rules | `assetlinks.json` grants `handle_all_urls` to this app’s package with every saved fingerprint, each a real SHA-256 (32 colon-separated hex bytes), over https with `application/json` |
| The SDK has reported | An install of this app (matched by bundle ID or package) came from an identified SDK build; the version is shown |
| A link was tapped | Any link of the account recorded a web tap |
| First install registered | An install of this app reached the server |
| First install attributed to a link | One of them was credited to a link; the slug is shown |
| First event received | The account received an `identify`, lead, conversion or custom event |
| Webhook configured (optional) | An active webhook takes this app’s events (its own, or an account-wide one); not counted in the total |
A platform you have not set up is greyed out and not counted. Each step that is not done says what to do next. On a local deployment (`http://localhost`) both file steps stay open, because Universal Links and App Links only work over https.
On the hosted service, or any deployment on a real domain, **Check `.`** fetches both files from your live link host in your browser, without following redirects. It reports a redirect, a 404, the wrong content type, or a host that does not answer at all, which usually means the DNS records for the apex and `*` are missing or not proxied.
### General
[Section titled “General”](#general)
Section text: “The app’s name and the web page links fall back to when the app can’t serve them (desktop continue button, last mobile fallback).”
| Field | What to enter | Rules |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| **App name** | Placeholder `My App`. | Required when creating: `App name is required`. When editing, an emptied name keeps the old one. |
| **Fallback URL (optional)** | Placeholder `https://myapp.com`. The web page a tap lands on when nothing else applies: desktop visitors (instead of the QR page) and phones with no store URL to go to. Links created with **App fallback URL (default)** inherit it. | http(s), or `fallback_url must be a valid http(s) URL`. |
### iOS
[Section titled “iOS”](#ios)
Section text: “Bundle ID and Team ID generate your Universal Links file; the store URL and scheme route taps.”
| Field | What to enter | Rules |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **iOS bundle ID** | Placeholder `com.example.myapp`. | Must look like `com.company.app`: at least two dot-separated parts of letters, digits and underscores, each starting with a letter. Error: `ios_bundle_id must look like com.company.app`. |
| **Apple Team ID** | Placeholder `10-char team id`. Hint: “Required for Universal Links”. | Exactly 10 uppercase letters or digits. Error: `ios_team_id must be a 10-character Apple Team ID (e.g. 8H7C9JL2K3)`. |
| **App Store URL** | Placeholder `https://apps.apple.com/app/id123`. Where iPhone taps go when the app is not installed. | The host must be exactly `apps.apple.com`. Error: `ios_store_url must be an apps.apple.com URL`. |
| **iOS app scheme** | The scheme only, shown with a `://` suffix (placeholder `myapp`). ⓘ “The custom URL scheme declared in the iOS app’s Info.plist (CFBundleURLSchemes). DecTog builds deep links as scheme://username/slug — type the scheme only.” | A letter followed by letters, digits, `+`, `-` or `.`; a pasted trailing `://` is removed. `http`, `https`, `ws`, `wss`, `javascript`, `file`, `data`, `blob`, `about` and `intent` are rejected with `ios_app_scheme must be a custom app scheme — the browser handles http/https/javascript itself, so it can never open the app`. Other invalid values get `ios_app_scheme must be a URL scheme (letters, digits, +, -, .) without :// — e.g. myapp`. |
Below the fields, **Universal Links file** `/.well-known/apple-app-site-association` has a **Verify** button and an ⓘ bubble with a four-step guide: the bundle ID and Team ID generate the file once both are saved; add an Associated Domains entitlement in Xcode; rebuild and reinstall, because iOS reads the file when the app is installed; and custom-scheme links only open from a direct user tap, never from a redirect. The bubble, and the setup prompt its **⧉ Copy for AI** button copies (with your domain, username, bundle ID and Team ID filled in), name the entitlement as the explicit host, `applinks:your-username.dectog.com`, and warn against the wildcard: for `applinks:*.dectog.com` iOS fetches the file from the parent domain, and `https://dectog.com/.well-known/apple-app-site-association` answers `400` with `Subdomain not found in host`, because only the `your-username.dectog.com` host (or `?u=your-username`) identifies the account. The platform side is covered in [Platform setup](/sdk/platform-setup/).
### Android
[Section titled “Android”](#android)
Section text: “Package name and SHA-256 fingerprints generate your App Links file; the store URL and scheme route taps.”
| Field | What to enter | Rules |
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Android package** | Placeholder `com.example.myapp`. | Same shape as the bundle ID. Error: `android_package must look like com.company.app`. |
| **Play Store URL** | Placeholder `https://play.google.com/store/apps/details?id=…`. Where Android taps go when the app is not installed. | The host must be exactly `play.google.com`. Error: `android_store_url must be a play.google.com URL`. |
| **Android app scheme** | Placeholder `myapp`, with the `://` suffix. ⓘ “The android:scheme registered in the app’s intent filters. Leave it equal to the iOS one if both platforms share a scheme.” | The same rules and messages as the iOS scheme, prefixed `android_app_scheme`. |
| **Facebook App ID (optional)** | Placeholder `1234567890123456`. ⓘ “The SDK reads it from here at start-up and uses it for Meta’s on-device install referrer (Facebook and Instagram apps, Android). A fbAppId set in DectogConfig in your app takes precedence.” | Free text, trimmed. Served to the SDK by `GET /api/v1/sdk-config`. See [Deterministic attribution](/sdk/deterministic-attribution/). |
| **SHA-256 fingerprints (one per line)** | Placeholder `AA:BB:CC:…`. Hint: “Include your release / Play App Signing key, not only the debug key.” | Each line must be 32 hex byte pairs separated by colons. Values are stored in uppercase; blank lines are dropped. Error: `SHA-256 fingerprints must be 32 hex byte pairs separated by colons (AA:BB:…)`. |
| **Other Android stores**: **Huawei AppGallery URL**, **Samsung Galaxy Store URL**, **Xiaomi GetApps URL**, **vivo store URL** | ⓘ “Optional. Devices from these brands are sent to their own store instead of Google Play — e.g. Huawei phones without Google services. Each store passes the install referrer back to the SDK, so installs still attribute deterministically. Leave empty to always use Google Play.” | Huawei: a host under `appgallery.huawei.com`, `appgallery.cloud.huawei.com` or `url.cloud.huawei.com`. Samsung: `galaxystore.samsung.com`, `apps.samsung.com` or `galaxy.store`. Xiaomi: `global.app.mi.com` or `app.mi.com`. vivo: any http(s) URL. Errors read `huawei_store_url must be a appgallery.huawei.com / appgallery.cloud.huawei.com / url.cloud.huawei.com URL` and so on, or `vivo_store_url must be a valid http(s) URL`. An OEM URL is used only for phones of that brand and works without a Play Store URL. |
Below the fields, **App Links file** `/.well-known/assetlinks.json` has its own **Verify** button and ⓘ guide: the package and fingerprints generate the file once both are saved; add an https intent filter with `android:autoVerify="true"` for each tenant host, listed explicitly because intent filters take no wildcards; Chrome verifies at install or update, and until then taps still open the app through an `intent://` redirect carrying your scheme and package. **⧉ Copy for AI** copies the prompt with your package and fingerprints filled in. How brands are detected and store URLs tagged is in [App stores and OEM routing](/links/app-stores-and-oem-routing/).
### React Native and Expo
[Section titled “React Native and Expo”](#react-native-and-expo)
Section text: “The react-native-dectog config plugin writes the Associated Domains and App Links setup above into the native projects. This app’s entry, filled in:”. Below it, the plugin entry for this app, ready for `plugins` in app.json: your username (the test environment’s while the dashboard shows it), the deployment’s link root when it is not `dectog.com`, the account’s custom link domain when it has one, and the iOS scheme (else the Android one) as `scheme`. **Copy** copies it. Run `npx expo prebuild` after adding it; the two file checks above confirm the server side, and `Dectog.diagnostics()` in the app the device side. See [React Native and Expo](/react-native/#platform-setup).
### Social sharing preview
[Section titled “Social sharing preview”](#social-sharing-preview)
Section text: “How your links look when shared on social media or in messaging apps. Used for every link without its own preview.”
| Field | What to enter | Rules |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **Image URL** | Placeholder `https://…/preview.jpg`. Hint: “JPG or PNG. Recommended size 1200 × 630 px (shown as a large card).” | http(s): `og_image_url must be a valid http(s) URL`. |
| **Title** | Placeholder `My App`. Hint: “Recommended 30 to 60 characters. Maximum 150 characters.” | The box does not stop you; the server rejects more than 150 characters with `og_title must be at most 150 characters`. |
| **Description (optional)** | A short text. | The box stops at 150 characters; the server enforces the same limit. |
A **Live preview** card renders the three values, captioned “How the app default looks when a link is shared on social media or messaging apps.” The per-link editor gives the same 1200 × 630 px hint; each platform crops as it likes. See [Social previews and QR](/links/social-previews-and-qr/).
**Auto-fill from fallback URL** appears in the section header when the Fallback URL is an http(s) address and at least one of the three preview fields is empty. It reads “Fetching…” while running, fills only the empty fields from the page’s `og:` and `twitter:` tags, and reports `✓ Filled from your fallback URL — review, then save.` or `✕` followed by the server’s error: `No title, description or image found on that page`, `Enter a valid public http(s) URL` (private and loopback hosts are refused), `Couldn't reach that URL`, or a message naming the HTTP status the page returned. The fetch follows redirects, gives up after 8 seconds or 512 KB, and cuts titles and descriptions to 150 characters. Nothing is saved until you save the form.
### Webhooks
[Section titled “Webhooks”](#webhooks)
Shown for a saved app. Section text: “Signed POSTs to your servers (HMAC-SHA256 in `X-Dectog-Signature`) for installs, conversions, opens, referrals and link changes. Every delivery is logged and retried for a day.” The list holds the webhooks that receive this app’s events: the app’s own and the account-wide ones. Each row shows its URL, a status chip (**On**, the number of failures in a row such as **3 failed in a row**, or **Off** with the reason), whether it takes **this app** or **every app**, and its events.
| Button | What it does |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Deliveries** | The last 20 deliveries: time, event, **Delivered** with the HTTP status, **Retry** with the time of the next attempt, or **Failed** with the error, the attempt number, the start of the response, and **Resend**. |
| **Send test** | Delivers a `webhook.test` event now and opens the log; a failure is shown above the list and does not count toward the thresholds. |
| **Rotate secret** | Asks first, then shows the new signing secret once; the old one stops working at once. |
| **Turn off**, **Turn on** | Stops or resumes deliveries. Turning on resets the failure count, and pending retries go out with the next hourly run. |
| **Delete** | Asks first, then deletes the webhook and its log. |
**Add webhook** asks for the **URL**, **Events of this app** or **Events of every app**, and the events, each with a short note (`click.recorded`, one delivery per tap, is not ticked at first). **Add webhook** then shows the signing secret once: “The signing secret. Copy it now: it will not be shown again.” The envelope, the events, retries and verification code are in [Webhooks](/api/webhooks/).
### Store notifications
[Section titled “Store notifications”](#store-notifications)
Shown for a saved app. **Turn on store notifications** creates the app’s two URLs:
| Item | Meaning |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **App Store Server Notifications URL** | Paste it in App Store Connect → the app → App Information → App Store Server Notifications, Version 2. Needs the app’s iOS bundle ID. |
| **Google Play Pub/Sub push endpoint** | The endpoint of a Pub/Sub push subscription on the topic named in Play Console → Monetization setup, with authentication on. Needs the app’s Android package. |
| **Pub/Sub service account** | The service account of that subscription. **Save account** stores it; pushes signed for any other account are refused. |
| **New URLs** | Replaces both URLs after a confirmation; the old ones stop working at once. |
| **Turn off** | Deletes both URLs after a confirmation. |
| **Refresh** | Reloads the list below. |
Below the buttons, the last 20 notifications: time received, store, notification type (with subtype and a non-production environment), transaction id, and the result (`recorded`, `updated`, `duplicate`, `ignored`, `unmatched`, `test`, `rejected`). What each notification records is on [Revenue](/sdk/revenue/#store-notifications).
### Advanced
[Section titled “Advanced”](#advanced)
Section text: “Attribution behaviour. Everything here is off by default.”
| Setting | Control | What it does, as the screen describes it |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Strict fraud mode** | Switch | “Off: installs that trip a fraud rule (emulator, impossible click-to-install timing, install velocity per IP, click storm, stale referrer) keep their attribution and carry the flags. On: flagged installs count as organic — the click they claimed stays consumed either way.” With the switch on, a flagged install is stored as unmatched, but the server keeps the referrer source, the confidence and the matched slug on the record as evidence. Neither the app’s verdict nor the webhook names the slug of an uncredited install. |
| **Probabilistic match** | Switch, on by default | “On: when no store referrer, copied link or Meta referrer names the click, the server compares the install with recent taps from the same network and credits a unique, corroborated match. Off: installs of this app are matched by those deterministic signals only, the comparison never runs, and its link pages stop reading the device settings. Apple’s rules prohibit fingerprinting; turn this off if your App Store review requires it.” The switch is read from the app the install belongs to (its package name or bundle ID). |
| **OEM store referrers** | Switch, on by default | “The SDK also reads the Huawei, Samsung, Xiaomi and vivo store referrers on Android. It picks this up at start-up; a value set in DectogConfig in the app takes precedence.” |
| **Pasteboard read at first launch** | Select: Platform default, On, Off | “Default: on for iOS while a page of yours copies the link (tap-to-store or the interstitial, on the app or on a link), since only those taps leave a link to read; off otherwise, and always off on Android. The read shows Apple’s paste alert on iOS 16+. The SDK picks this up at start-up; a value set in DectogConfig in the app takes precedence.” |
| **Mobile beacon interstitial** | Switch | “Off: mobile taps get an instant redirect to the store or app. On: taps see a brief “Opening the app…” page that records the screen profile on the click, which sharpens install matching. Adds roughly half a second.” When iOS tap-to-store is on, the row shows an **Android only** badge and continues “iPhone taps are handled by iOS tap-to-store (its page sends the same beacon), so this switch now affects Android only.”; otherwise it continues “On iPhone, iOS tap-to-store takes over when it is on; Android taps still get this page.” |
| **iOS tap-to-store** | Switch | “Off: iPhone taps go straight to the App Store. On: taps see a one-button page whose “Get the app” tap copies the link to the clipboard before opening the App Store. The SDK reads it on first launch, so iOS installs attribute deterministically instead of by IP. Inside Instagram and Facebook the page offers “Open in app” first. There is no automatic redirect: Safari only lets the page copy from a tap. On iPhone this replaces the beacon interstitial.” The page is used only when the tap’s destination is the App Store. |
| **Low-entropy attribution window** | Number box, 1 to 720, placeholder `2`, suffix “hours” | “Hours an IP-only click (no device model or screen profile to compare, or an install on mobile data) stays matchable. Shorter is safer on shared carrier networks. Empty uses the deployment default (2 hours); clicks whose device model or screen profile can be compared with the install keep the full 24-hour window.” The server accepts a whole number from 1 to 720 or an empty box; anything else gets `low_entropy_window_hours must be a whole number of hours between 1 and 720`. The effective value never exceeds the wide window of 24 hours. |
| **MMP click handoff** | Switch, and a **Media source (pid)** box (optional, letters, digits, `.`, `-`, `_`, up to 100) shown when on | “For a migration from AppsFlyer, Singular or Adjust: when a link sends visitors to one of their tracking links (onelink.me, sng.link, app.adjust.com, adj.st), add DecTog’s click id as clickid, so both tools report the same tap and their verdicts can be compared. A clickid or pid already in the link wins.” See [Running both for a while](/guides/migrate-from-branch-adjust-appsflyer/#running-both-for-a-while). |
| **Pasted-link and Meta window** | Number box, 1 to 720, placeholder `168`, suffix “hours” | “Hours a copied link (iOS tap-to-store, paste button) or a Meta install referrer still earns credit. An older click sends the user to the link but counts the install as organic, flagged stale_clipboard or stale_meta; with strict fraud mode on it does not even route. Empty uses the deployment default (168 hours, 7 days). Store referrers keep 30 days.” The server accepts a whole number from 1 to 720 or an empty box. |
| **Re-engagement window** | Number box, 1 to 720, placeholder `168`, suffix “hours” | “Hours after one of this app’s links re-opened the installed app during which purchases and other events count for that link instead of the link that brought the install. Empty uses the deployment default (168 hours, 7 days).” Applied when the dashboard reads the numbers, so a change also moves past credit. |
Strict fraud mode and both windows are read from the app the install concerns (see the note at the top of this page). The interstitial and tap-to-store switches belong to the app a link uses. The fraud rules, windows and confidence levels are explained in [How attribution works](/start/how-attribution-works/) and [Attribution internals](/self-hosting/attribution-internals/).
## Saving
[Section titled “Saving”](#saving)
A sticky bar appears at the bottom once the form differs from what is saved, with **Save changes** (or **Add app** for a new app) and **Cancel**. While nothing is wrong it reads “Changes apply to new link taps right after saving.” or “New apps can be edited any time.” A rejected save keeps the bar up and replaces that text with the server’s message. On success the editor closes and the status line shows “Saved ✓”.
Updates are partial on the server: a field left out keeps its value, and an emptied field clears it (except the name). Identifier fields are validated after trimming but saved exactly as typed, so avoid leading or trailing spaces.
## Verify
[Section titled “Verify”](#verify)
Each **Verify** button fetches the account’s well-known file through the dashboard host with `?u=your-username`, which returns exactly what `https://your-username.dectog.com/.well-known/…` serves publicly. It checks the **saved** configuration of **every app** in the account, not the unsaved editor, and shows one line:
| Button | Result |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Universal Links file | `✓ app ID(s) served`, or `✕ No app IDs configured` when no app has both a bundle ID and a Team ID. |
| App Links file | `✓ package(s), SHA-256 fingerprints present`; `✕ package(s), MISSING fingerprints` when no package has a fingerprint; or `✕ No packages configured`. |
| Either | `✕ Endpoint replied : ` (the server answers `404` with `Configuration not found` while no app qualifies) or `✕ Fetch failed: `. |
The AASA file lists every app that has both a bundle ID and a Team ID, as `TEAMID.bundle`, under `applinks` and `webcredentials`. The assetlinks file lists every app that has a package, with its fingerprints (an empty list when none are set). Both are described in [Well-known, SEO and routing](/api/well-known-seo-and-routing/).
## Deleting an app
[Section titled “Deleting an app”](#deleting-an-app)
The trash icon asks `Delete app ""? Links using it will fall back to your default app. This cannot be undone.` Links bound to the app lose their binding and are served by the oldest remaining app from the next tap on; so do link templates that named it, and the links made from them afterwards. The webhooks limited to the app are deleted with their delivery logs, and its store notification URLs stop working; account-wide webhooks stay. Clicks and installs recorded for the app stay. The status line shows “Deleted ✓”.
Self-hosting
The well-known files are served on your own root domain, so the Associated Domains entry becomes `applinks:your-username.your-root-domain` and the intent-filter hosts become `your-username.your-root-domain`. In path mode (a root domain that starts with `localhost`) the files are reachable only with `?u=your-username`, and on-device verification, which fetches them from the link host over https, is not possible.
# Blog and messages
> The owner-only Blog, Categories and Messages screens, the limits of the public contact form, and where the public blog is served.
**Blog** and **Messages** are the site operator’s screens: they manage the public blog at and the inbox of the public contact form. They appear in the sidebar only for the account with the `owner` role. Other accounts that open `/dashboard/blog` or `/dashboard/messages` are redirected to the Overview, and the API answers `403 Owner access required`. Who the owner is, and why signup never creates one, is explained in [Account and sign-in](/dashboard/account-and-sign-in/).
Unlike every other screen, these are not scoped to an account: there is one blog and one inbox per deployment.
## Blog posts
[Section titled “Blog posts”](#blog-posts)
`/dashboard/blog`, titled **Blog posts** (“Create and manage your blog content here.”). The screen loads up to 200 posts, drafts included, and searches, sorts and pages them in the browser.
| Element | Behavior |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **New post** | Opens the post editor. |
| **Categories** | Goes to `/dashboard/blog/categories`. |
| **Search** | Filters by title or category name. |
| Table | Checkbox · **Title** (with the slug underneath) · **Category** · **Created** · **Last edited** · **Status** (**Published** or **Draft**). Clicking **Category**, **Created** or **Last edited** sorts by that column; the default is Last edited, newest first. Clicking a row opens it in the editor. |
| Selection | The header checkbox selects the current page. With a selection, the toolbar reads `{n} selected` with **Delete** and **Cancel**. **Delete** asks “Delete {n} posts? This cannot be undone.” first. |
| Pagination | 10 posts per page, with numbered pages and **Previous** / **Next**. |
Empty states: “No blog posts yet” with a **Create your first post** button, and “No posts match your search”.
### The post editor
[Section titled “The post editor”](#the-post-editor)
A side panel. The top bar has a close button, a **Publish now** switch, a **⋯** menu with **Save as draft** and, for an existing post, **Delete**, and the main button, which reads **Publish** when the switch is on and **Save** otherwise.
| Field | What to enter | Rules |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Title** (required) | Placeholder `Post title...`. Typing a title fills the slug while the slug is still empty. | 1 to 200 characters. |
| **Slug** (required) | Placeholder `post-url-slug`; a preview shows `/blog/{slug}`. | Lowercase letters and digits in groups separated by single `-` or `_`. The server accepts letters, digits, `-` and `_` up to 128 characters and refuses a slug another post uses: `A post with this slug already exists`. |
| **Category** | A **Select category** dropdown; the chosen category shows as a chip with an **X** to clear it. | Required to publish: the text “Category is required for publishing” appears when the switch is on and no category is chosen. Drafts need none. |
| **Image** | A URL (placeholder `https://example.com/cover.jpg`) with a thumbnail and a remove button. There is no upload. | http(s): `Featured image must be a valid http(s) URL`. Posts without an image get a stock cover on the public blog. |
| **Summary** | Placeholder `Brief description of the post...`. Hint: “This appears on blog listing pages and in search results”. | Up to 500 characters. |
| **Content** | A rich-text editor with Undo, Redo, Bold, Italic, Strikethrough, Inline code, Heading 1 to 3, Bullet list, Numbered list, Quote, Code block, Divider, **Insert image URL** and **Add link**. Image URLs must start with `http://` or `https://`. Links must start with `https://`, `http://`, `mailto:`, `tel:`, `/` or `#`. | Required: `Content is required`. |
Saving creates or updates the post. The first publish stamps the publication date; unpublishing later keeps it. On success the panel closes and the list reloads. The editor checks the title, slug, summary and category before sending; when a check fails or the server rejects the save, the panel stays open and a toast in the top-right corner names the problem.
**Delete** in the **⋯** menu asks **Delete post**: “Are you sure you want to delete this post? This action cannot be undone.”
## Categories
[Section titled “Categories”](#categories)
`/dashboard/blog/categories`, titled **Categories** (“Organize your blog content.”). **New category** opens the editor; **Search** filters by name or slug; the table shows a checkbox, **Name** with the slug underneath, and **Created**, 10 per page. Bulk **Delete** on a selection asks “Delete {n} categories? This cannot be undone.” first. Empty states: “No categories yet” and “No categories match your search”.
| Field | Rules |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** (required) | 1 to 80 characters. While creating, typing a name fills the slug. |
| **Slug** | Hint: “Used in blog URLs and filters”. Letters, digits, `-` and `_`; generated from the name when left empty; must be unique: `A category with this slug already exists`. |
**Delete** in the editor’s **⋯** menu asks **Delete category**: “Are you sure you want to delete this category? Posts using this category will be unaffected but will no longer have a category assigned.” The public blog lists only categories that have at least one published post.
## Messages
[Section titled “Messages”](#messages)
`/dashboard/messages`, titled **Messages** (“Messages sent through the contact form.”). The screen loads the newest 200 messages. Each card shows the subject, the time it arrived in your local time, a trash icon, `{name} · {email}` with the email as a `mailto:` link, and the message text. There is no search, no pagination and no read or unread state.
The trash icon asks **Delete message**: “Are you sure you want to delete this message? This action cannot be undone.” Empty state: “No messages yet.” If the list cannot be loaded: “Could not load messages.”
### The public contact form
[Section titled “The public contact form”](#the-public-contact-form)
Messages come from (“Get in Touch”). The form is checked in the browser and again by the server:
| Field | Rules |
| ----------- | -------------------------------------- |
| **Name** | 1 to 100 characters. |
| **Email** | A valid address, up to 200 characters. |
| **Subject** | 1 to 150 characters. |
| **Message** | 10 to 2,000 characters. |
The server accepts at most 5 submissions per hour per IP address. Submissions are counted before validation, so rejected ones use up the allowance; the sixth gets `Too many messages — try again later`. A message is only stored; no email is sent to anyone. The sender sees “Message sent!” with “Thank you for contacting us. We’ll get back to you soon.”, or “Message not sent” with the error.
## Where the public blog lives
[Section titled “Where the public blog lives”](#where-the-public-blog-lives)
* The blog is served by the site host: `https://dectog.com/blog` and `https://dectog.com/blog/{slug}`.
* `https://app.dectog.com/blog` and `https://app.dectog.com/blog/{slug}` answer with a permanent redirect (301) to the same path and query string on the site host, like the other site pages asked of the dashboard host.
* Only published posts are public; a draft’s URL returns a “not found” response. The public blog page loads up to 50 posts, with a search box and a category filter.
* A shared link to a published post previews as that post: its title, its excerpt and, when set, its featured image. See [Blog post pages](/api/well-known-seo-and-routing/#blog-post-pages).
* `https://dectog.com/sitemap.xml` lists the site pages and every published post, newest first; see [Well-known, SEO and routing](/api/well-known-seo-and-routing/).
Self-hosting
The blog and the contact form are served on the worker’s `SITE_URL` host, and the dashboard host (`WEB_URL`) redirects their paths there. The screens exist on every deployment; whether you use them is up to you.
# Customers
> The Customers screen lists the users your app identified with the SDK and shows, for each one, the click and install that brought them in, their devices, events and app opens.
The Customers screen, at , lists every customer your app has identified by calling `identify` in the SDK, together with the link that brought each one in. Its header reads “Users your app identified with the SDK’s `identify` — and the link that brought them in.” The screen is read-only: there is no edit and no delete.
A customer exists as soon as the SDK sends an `identify` event with a customer ID. How to send one, and what the server stores, is in [Events](/sdk/events/).
## The list
[Section titled “The list”](#the-list)
| Element | Behavior |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Search** (placeholder “Search name, email, phone or ID”) | Waits 250 ms after you stop typing, then searches on the server for the text anywhere inside the customer ID, name, email or phone. The query is cut to 200 characters. Every new search returns to page 1. |
| Rows | Clicking anywhere on a row opens the customer’s detail page. The name is also a link, for keyboard users. |
| Pagination | 50 customers per page, newest first. A footer reading `1–50 of 312` with **Previous** and **Next** appears only when there are more than 50 results. |
### Columns
[Section titled “Columns”](#columns)
| Column | What it shows |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **First seen** | The date and time of the customer’s first `identify`. |
| **Customer** | The name, or the customer ID when there is no name. Under it: the email masked as `e****@domain.com` (the detail page shows it in full), or the ID in monospace when there is a name but no email. An amber **Shared device · {n}** chip appears when other customers sent events from one of this customer’s devices; its tooltip reads “Shares a device with {n} other customer(s)”. |
| **Attributed link** | `/{slug}` of the link whose click was matched to the customer’s install, or **Organic**. |
| **Country** | A flag and the country name of the matched click. **Unknown** for organic customers and for clicks without a country. |
Empty states: “No customers yet — call `DecTog.identify(customerId: …)` in your app after sign-in.” and “No customers match your search.”
### How a customer is tied to an install
[Section titled “How a customer is tied to an install”](#how-a-customer-is-tied-to-an-install)
The server resolves one install per customer, trying in order:
1. the install ID the first `identify` call carried;
2. the install ID stamped on the customer’s earliest event that has one;
3. the newest install on any device that sent the customer’s events, preferring attributed installs.
The third step matters because the SDK forgets the install ID across restarts; the device ID still links the events to the install.
## The detail page
[Section titled “The detail page”](#the-detail-page)
Opening a row loads `?id={customer_id}`. The page starts with an **← All customers** link, then the name (or ID), the ID in monospace, and the shared-device chip when applicable. “Loading customer…” shows while it loads; an unknown ID shows the server’s error `Customer not found`.
### Stats
[Section titled “Stats”](#stats)
Four tiles: **Events**, **Leads** (events of type `lead`), **Conversions** (events of type `conversion`) and **Net revenue**: purchases minus refunds in your revenue currency. The profile also shows the date of the customer’s **First purchase**.
### Profile
[Section titled “Profile”](#profile)
Subtitle: “Set by identify(). A later call replaces each field it sends; fields it leaves out keep their value.”
| Field | Value |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name**, **Email**, **Phone** | As sent by `identify`. A later `identify` that sends a field overwrites the stored value; a field the later call omits keeps its old value. The subtitle’s “first-seen values win” describes only the install association: the first install a customer was identified on stays attached. The email is shown in full here. |
| **Customer ID** | Your identifier. |
| **First seen** | When the customer record was created. |
| **Last identified** | The last time an `identify` touched the record. |
| **First event**, **Last event** | From the customer’s events. |
| **Devices** | Up to 20 distinct device IDs seen on the customer’s events, plus the install’s device. |
Empty fields are hidden. Name, email and phone are stored in plain text; see [Privacy and data](/self-hosting/privacy-and-data/).
### Attribution
[Section titled “Attribution”](#attribution)
Subtitle: “The link click that led to this customer’s install. Open install shows why it matched or stayed organic.” Empty state: “No install linked to this customer yet.”
| Field | Value |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Attributed link** | `/{slug}` as a link to the live URL for a permanent link; the slug in plain monospace when the link was temporary; **Organic** otherwise. |
| **Match** | **Install referrer (deterministic)**, **Copied link (deterministic)**, **Device fingerprint (probabilistic)** or **Organic**. |
| **Referrer source** | Which store or SDK handed over the referrer: `google_play`, `huawei`, `samsung`, `xiaomi`, `vivo` or `meta`. |
| **Clicked at**, **Installed at** | The server-side times of the matched click and of the install report. |
| **Country**, **Region**, **Network** | From the click: the country and city, the region, and the network provider with its AS number. |
| **Click device** | The click’s device type, OS and browser, such as `android · Android 14 · Chrome`. |
| **Referrer** | The origin of the page that sent the click. |
| **UTM** | The click’s `utm_*` parameters, as `source=… · medium=… · campaign=…`. |
| **Link params** | The tracking values stored on the click, as `label=… · campaign=… · adgroup=… · creative=… · tracker=…`, from the link’s Tracking fields or `dec_*` overrides. |
| **Deep link** | The `dec_deeplink` route the click carried. |
| **Click IP** | The raw IP address of the click; `—` when the deployment sets `STORE_RAW_IP=0`. |
| **Fraud flags** | Amber chips with the rule names the install tripped, such as `emulator` or `click_reuse`. |
Below the fields, **Open install** opens the [install detail](/dashboard/overview/#recent-installs) with its attribution parts only: the verdict, its **Why** (the signal that named the click with the tap it named, signal by signal, or the fingerprint’s candidates with each signal marked agree, mismatch or missing), the tap and the device’s data. That is where an organic customer’s near misses show. It leaves out the app opens, events and screens, which this page already lists for the customer.
What each match method and confidence means is explained in [How attribution works](/start/how-attribution-works/).
### Shared device
[Section titled “Shared device”](#shared-device)
Shown only when other customers share a device. Subtitle: “Other customers identified on one of this customer’s devices — account sharing, multiple accounts, or a test device.” A table of **Customer** (a link to that customer, with the email), **Device ID** and **Last seen on device**, up to 50 rows.
### App & device
[Section titled “App & device”](#app--device)
Subtitle: “Reported by the SDK at install.” Shown when the customer has an install; empty values are hidden.
**Platform**, **App version** (with the build number in parentheses), **Package**, **OS** (name and version), **Device** (manufacturer and model), **Device kind**, **Physical device** (Yes or No), **Reinstall** (Yes or No), **Screen** (such as `1080×2400 @2.625x`), **CPU**, **Language**, **Timezone**, **Connectivity**, **Carrier (MCC/MNC)**, **Installer** (the package that installed the app), **Install time (device)**, **Install IP** (the raw IP of the install report; `—` when the deployment sets `STORE_RAW_IP=0`), **Device ID** and **Install ID**.
Which of these the SDK sends depends on its data tier; see [Device data and privacy](/sdk/device-data-and-privacy/).
### Events
[Section titled “Events”](#events)
Titled **Events ({total})**, where the total counts every event of the customer. The table shows the latest 200, newest first.
| Column | Value |
| ------------ | ------------------------------------------------------------------------------------ |
| **When** | Date and time. |
| **Type** | A chip: `conversion` (green), `lead` (orange), `identify` (blue) or `custom` (gray). |
| **Name** | The event name, or `—`. |
| **Revenue** | The amount followed by its currency code, such as `9.99 EUR`, or `—`. |
| **Link** | `/{slug}` when the event is attributed, or `—`. |
| **Metadata** | The event’s JSON, shortened; hover to see it formatted in full. |
Empty state: “No events for this customer.”
### Screens
[Section titled “Screens”](#screens)
Subtitle: “App sessions on this customer’s devices, newest first, with the screens shown in each: the whole session, so the screens before sign-in too. Screen views count in no total.” A session is one run of the app, by the SDK’s session id; it belongs to the customer when they signed in (`identify`) during it or it ran signed in, so the screens before `identify` show with the rest. Each session shows its date and time (the device’s clock at its first screen), the link that opened the app when there was one (**opened by** `/{slug}`), how many screens it had, and the screens in order, each with its time on hover. The 20 newest sessions are listed, with the first 50 screens of each and “and {n} more” beyond. The screens come from the SDK’s [`trackScreenView` or its navigator observer](/sdk/events/#screen-views); without them the section says so. The install detail on [Overview](/dashboard/overview/#recent-installs) has the same list for one install.
### App opens
[Section titled “App opens”](#app-opens)
Shown only when there are any. Subtitle: “Times a DecTog link re-opened the installed app on this customer’s devices.” Each row is `/{slug}` with the date and time; the latest 50 across the customer’s devices.
Self-hosting
The screen behaves the same on a self-hosted deployment. The daily retention job blanks a customer’s name, email and phone after the configured retention period; see [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/).
### Personal data
[Section titled “Personal data”](#personal-data)
At the bottom of the page: **Download everything stored (JSON)** saves what DecTog holds about the customer (the record, its events, its referrals and its devices), and **Erase this customer** deletes it after a confirmation, together with each device only this customer used. A device another customer also used keeps that customer’s data. The page then shows what went, for example “Customer erased: 1 customer record, 1 device, 1 install, 5 events”. Both are recorded in the [audit log](/dashboard/settings/#audit-log) without the customer id. What each covers is in [A request from one person](/self-hosting/privacy-and-data/#a-request-from-one-person).
# Links
> Create permanent and temporary links, read the per-link numbers, edit tracking, previews and routing, and pause, expire, archive, delete and restore links.
The Links screen, at , is where you create the links people tap. Its header reads “Deep links that open your app, track installs and expire when you want.” Next to it, **Templates** manages [link templates](#templates), **Import CSV** creates many links from a file ([Importing links](#importing-links)), and **New link** opens the create panel; the panel is always open while the account has no links.
On load the screen fetches your links, your apps (an account with exactly one app gets it preselected) and your username, which the deep-link previews use.
## Creating a link
[Section titled “Creating a link”](#creating-a-link)
The **New link** panel has four fields. Submit with **Create link**, or **Create temporary link** when an expiry is set. The button reads “Creating…” while the request runs. Errors appear in red next to the button, verbatim from the server. On success the form resets, the panel closes and the list switches to the matching tab.
| Field | What to enter | Rules |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Fallback URL** | Either **App fallback URL (default)**, which inherits the Fallback URL of the selected app, or **Custom URL** with an address (placeholder `https://destination.com`). For temporary links the first option reads **Default app fallback**. Hint: “Where this link points when the app can’t serve it (desktop continue button, last mobile fallback). The default inherits the app’s Fallback URL from the Apps page.” | The app option is disabled, with the tooltip “Set a Fallback URL on the Apps page to enable”, while the app has no Fallback URL; **Custom URL** is then the only choice. A custom URL must be http(s), otherwise the server replies `Invalid target: must be a valid http(s) URL`. |
| **Link URL** | The slug after your link domain (placeholder `launch`, or `auto` for temporary links). The prefix shows the host of your existing links, or `/` until you have one. | Letters, digits, `_` and `-` only, up to 128 characters. Required for permanent links (`Invalid or missing slug`). Slugs starting with `r-` are kept for referral links (`Slugs starting with 'r-' are reserved for referral links`). Optional for temporary links: leave it blank for a generated slug of the form `t_` plus 10 hex characters; the hint reads “Optional — leave blank for an auto-generated slug. Temporary links use your default app’s routing and fallback.” A slug must be unique across your permanent **and** temporary links, otherwise `Slug already exists`. |
| **App** | **Default app** or one of your apps. Tooltip: “App used for platform routing”. Hidden for temporary links and when you have no apps. | **Default app** means your oldest app. The app decides store URLs, schemes, interstitial pages and the social preview for this link. |
| **Expires** | **Never** (default), **5 min**, **1 hour**, **1 day** or **7 days**. | Anything other than **Never** creates a temporary link. The dashboard offers only these four durations; the API accepts 60 seconds to 30 days. |
When the account has [templates](#templates), a **Template** box next to **App** picks one for a permanent link. A permanent link is sent to `POST /api/admin/links`; a temporary one to `POST /api/admin/temp-links` with the chosen `ttlSeconds`. Temporary links always route through the default app and have no click counter, no deep-link paths, no tracking fields and no routing overrides. See [Temporary links](/links/temporary-links/).
### Advanced options
[Section titled “Advanced options”](#advanced-options)
**Advanced options** appears under the form for permanent links once the account has at least one app. Its header shows an orange chip for every section in use: `fallback URL`, `deep link`, `redirect`, `desktop fallback`, `tracking`.
#### Deep link · the route your app opens
[Section titled “Deep link · the route your app opens”](#deep-link--the-route-your-app-opens)
Hint: “Optional app route this link opens — the path becomes the URI (e.g. scheme://login), so the app receives it exactly as typed. Without a path, links open scheme://username/slug. Schemes come from the selected app (manage them on the Apps page).”
| Field | What to enter | Rules |
| -------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **iOS deep link path** | A route such as `products/42?color=black`. | Disabled, with the placeholder `no scheme set` and the hint “Set the iOS scheme on the Apps page to enable this path.”, while the selected app has no iOS scheme. A live preview shows `scheme://path`, or `scheme://username/slug` while the box is empty. |
| **Android deep link path** | Same, for Android. | The same rules, with the Android scheme. |
Server rules for both paths: at most 512 characters; no `:`, whitespace, `#`, `;` or control characters, so no full URLs; a leading `/` is removed. The errors read `Invalid ios_deep_link_path: must be a relative path, not a full URL (no ":": no scheme, no spaces, no "#"/";")` and `Invalid ios_deep_link_path: deep link path is too long (max 512 chars)`, or the same with `android_deep_link_path`.
A `dec_deeplink` parameter on the tapped URL replaces the saved path for that tap; see [URL parameters](/links/url-parameters/).
#### Tracking · label, campaign, adgroup, creative
[Section titled “Tracking · label, campaign, adgroup, creative”](#tracking--label-campaign-adgroup-creative)
Hint: “Saved on every click of this link and returned to your app after install. Override any of them per share by adding ?dec_campaign=… (or dec_label, dec_adgroup, dec_creative) to the link URL.”
| Field | Placeholder | Rules |
| ------------ | ------------------- | --------------------------------------------------------------------------------- |
| **Label** | `partner-42` | Up to 255 characters, no control characters: `Invalid label: max 255 characters`. |
| **Campaign** | `spring_sale` | Same. |
| **Adgroup** | `instagram_stories` | Same. |
| **Creative** | `video_15s` | Same. |
These values are stored on every click of the link, can be overridden per tap with `dec_label`, `dec_campaign`, `dec_adgroup` and `dec_creative`, and come back to the app in the install verdict and in the `app.installed` webhook. They do not feed the Overview’s campaign and source charts, which read `utm_campaign` and `utm_source`; see [Overview](/dashboard/overview/).
#### If the app isn’t installed (mobile)
[Section titled “If the app isn’t installed (mobile)”](#if-the-app-isnt-installed-mobile)
**App Store / Play Store (default)** sends a phone without the app to the store configured on the app: the App Store, Google Play, or the OEM store that matches the phone’s brand. **Custom URL** (placeholder `https://promo.example.com/offer`) sends it to that address instead. When the custom address is itself a store page on an Apple, Google Play, Huawei, Samsung or Xiaomi host, DecTog adds its attribution tag to it; vivo store addresses are not tagged. Stored as `redirect_url`; must be http(s).
#### If the app can’t open the link (desktop)
[Section titled “If the app can’t open the link (desktop)”](#if-the-app-cant-open-the-link-desktop)
**Branded QR page (default)**, or **App fallback URL (default)** when the selected app has one, or **Custom URL** (placeholder `https://web.example.com/page`). Stored as `fallback_url`; must be http(s). This is the last stop for desktop visitors and for phones that have no destination at all. With no custom URL, the tap falls through to the app’s Fallback URL and, if there is none, to the QR page.
The complete decision order for a tap is in [How a tap is routed](/links/how-a-tap-is-routed/). In short, on a phone the chain is: custom redirect, then the tagged store, then the app’s Fallback URL (which outranks this link’s own Fallback URL), then the link’s Fallback URL. Desktop visitors are never redirected to the link’s Fallback URL automatically; they get a “Continue to {host} on desktop” button on the QR page.
## The list
[Section titled “The list”](#the-list)
Three tabs, **Permanent {n}**, **Temporary {n}** and **Referrals {n}**, sit above a table. Next to them, **Active**, **Archived** and **Deleted** pick which links the tabs show: active links (paused and expired ones included), archived links, or deleted links, with a count once there is one. The **Search links** box filters the current tab in the browser, case-insensitively: slug, destination, campaign and label on the Permanent tab; slug and destination on the Temporary tab; slug, customer and campaign on the Referrals tab. There is no pagination; the screen loads every permanent and temporary link, and the referral links when their tab opens.
### Permanent links
[Section titled “Permanent links”](#permanent-links)
| Column | What it shows | Period |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **Link** | `/slug`, which opens the live link in a new tab; a copy icon that turns into a check mark for 1.5 seconds; chips for the campaign and the label when set, and **Paused** (amber; hover for your reason), **Expired** (red; hover for where taps go) or **Archived**; underneath, the destination host and path, or `App fallback` when the link inherits the app’s Fallback URL, then `· expires in 3d 4h` when an expiry is set. | |
| **Clicks** | The link’s click counter. It increases on every web tap, every click reported by the SDK and every click the server reconstructs from an install referrer, and it never decreases. A green `+{n} 7d` underneath counts the clicks of the last 7 days. | Lifetime; the `7d` line covers a rolling 7 days |
| **Installs** | Installs matched to one of this link’s clicks, excluding reinstalls. Underneath, `{n}% conv.` is installs ÷ the click counter, rounded to a whole percent, shown only when both are above 0. | Lifetime Click the number to list those installs; each opens the install detail described on [Overview](/dashboard/overview/#recent-installs). |
| **Opens** | Times this link opened the already installed app. | Lifetime |
| **Leads** | Events of type `lead` credited to this link: those from installs it brought, plus those within the re-engagement window after it re-opened the app. | Lifetime |
| **Revenue** | Net revenue (purchases minus refunds) credited to this link, in your revenue currency; `—` when 0. A second line, `… after re-open`, shows the part credited because the link re-opened the installed app within the app’s re-engagement window. | Lifetime |
| **Web** | Leads, purchases and custom events [`dectog.js`](/sdk/web/) sent from your website for this link, with their net revenue below; `—` when none. Part of Leads and Revenue, not on top of them. | The retention window |
| **Created** | The creation date, such as `Oct 12, 2026`. | |
All per-link numbers are all-time totals, which is why they do not match the Overview’s 7-, 30- or 90-day figures.
Each row ends with **Edit** (a pencil; the label reads “Edit (custom social preview)” and the icon carries a dot when the link has its own preview), **Screens after open** (a numbered list), **Pause** or **Resume**, **Archive** or **Unarchive**, and **Delete** (a trash can). On the **Deleted** view the row offers **Restore** and **Delete permanently** instead. See [Pausing, archiving and deleting](#pausing-archiving-and-deleting).
### Screens after open
[Section titled “Screens after open”](#screens-after-open)
The numbered-list icon opens a side panel with the screens people reached in your app right after this link opened it, for the last 7, 30 or 90 days. An open is the first launch of an install credited to the link, or a later re-open through it; a screen counts once per open when it was viewed within 30 minutes, an app restart in those minutes included. A link tapped while the app was already running keeps that run’s screens with the open that started it. **Reached** is how many opens got to the screen, with its share of the opens that reported any screen; **First screen** is how many started there, which shows whether the link’s deep link landed people where you meant. At most 20 screens are listed. The screens come from the SDK’s [`trackScreenView` or its navigator observer](/sdk/events/#screen-views); without them the panel stays empty. Screen views count in no event total on this page or any other.
### Temporary links
[Section titled “Temporary links”](#temporary-links)
| Column | What it shows |
| ----------- | ---------------------------------------------------------------------------------------------- |
| **Link** | `/slug`, the copy icon, and the destination or `App fallback`. |
| **Expires** | A red **Expired** chip, or a green countdown such as `42m left`, `3h 5m left` or `2d 4h left`. |
Temporary links show no click, install or revenue numbers.
**Delete expired** appears on the Temporary tab of the Active view once at least one link has expired. It asks “Delete all expired temporary links? They leave this list. Their clicks, installs and revenue stay in your stats, and you can restore them from Deleted.” and deletes every temporary link whose expiry has passed, as the trash icon does: they move to the **Deleted** view with their clicks and installs, and keep their slugs until you restore them or delete them permanently. The server’s daily job does the same automatically 24 hours after a link expires, except for links with an expired URL, which keep sending taps there.
### Referral links
[Section titled “Referral links”](#referral-links)
Your app makes one referral link per customer with [`createReferralLink`](/guides/referral-programs/); they are listed here rather than among the permanent links.
| Column | What it shows | Period |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------- |
| **Customer** | The customer the link belongs to, which opens their page. Viewers see `—`. | |
| **Link** | `/slug`, the copy icon, a chip for the campaign when it is not `referral`, and the **Paused**, **Expired** or **Archived** chips. | |
| **Clicks** | The click counter, with `+{n} 7d` underneath. | Lifetime |
| **Installs** | Installs credited to the link, excluding reinstalls. Click the number to list them. | Lifetime |
| **Referrals** | New customers who signed up after installing through the link: the referrals recorded for the customer. | Lifetime |
| **Created** | The creation date. | |
A referral link has no **Edit**: the server made it for one customer. Each row offers **Screens after open**, **Pause** or **Resume**, **Archive** or **Unarchive**, and **Delete**; pause or delete the link of a customer who abuses the program. A deleted referral link stays the customer’s: `createReferralLink` answers `410 link_deleted` for them until you restore it.
Empty states: “No permanent links yet.”, “No temporary links. Pick an expiry when creating a link to make one.” and “No links match your search.”
## Templates
[Section titled “Templates”](#templates)
**Templates** opens a panel listing your templates with the fields each sets and how many links use it. **New template** and **Edit** show **Name**, **App for new links**, and the fields a template can fill: **Desktop destination**, **Mobile without the app**, the iOS and Android deep link paths, **Campaign**, **Label**, **Adgroup**, **Creative** and the UTM source, medium and campaign. A link that uses a template takes its value for every field the link leaves empty, at tap time: editing a template changes all its links. **Delete** asks first and names how many links use it; they keep their own fields and stop inheriting. The create form and the link drawer pick a template under **Template**. Fields only the API sets (the target, the social preview, the page settings) are kept when you edit a template here. See [Templates](/api/links/#templates).
## Importing links
[Section titled “Importing links”](#importing-links)
**Import CSV** opens a panel with two column presets: **DecTog columns** (headers named like the API fields, such as `slug`, `target`, `campaign`, `utm_source`, `external_id`) and **Firebase Dynamic Links export** (see [Importing an FDL export](/guides/migrate-from-firebase-dynamic-links/#importing-an-fdl-export)). After you pick a file, the panel shows how many links it found, the first ten with notes (a row without a slug, `afl` and `ifl` that differ), and **Import {n} links**. Links are created 100 per request; the result reads “{n} created, {m} failed.” with a table of the failed rows by line number and error. Nothing is created until you press the button.
## Editing a link
[Section titled “Editing a link”](#editing-a-link)
**Edit** opens a side drawer titled **Edit link** `/slug`. Permanent links get five blocks; temporary links get the expiry and the social preview.
### Template and tracking
[Section titled “Template and tracking”](#template-and-tracking)
Permanent links only. **Template** (when the account has templates) picks the link’s template or **None**; an empty field below then shows the template’s value as its placeholder, followed by “(template)”. Below it, the label, campaign, adgroup and creative from the create form, and the link’s **UTM source**, **UTM medium**, **UTM campaign**, **UTM term** and **UTM content** (added to its web destinations; see [Link-level UTM values](/links/url-parameters/#link-level-utm-values)), each up to 255 characters, then **Add the click id to website destinations**, which adds `dtg_click` to the link’s web URLs (never a store or an app scheme) for [`dectog.js`](/sdk/web/) on that site. **Save template and tracking** sends them all; an emptied field clears the stored value. The button is followed by “Saved” or the server’s error.
### Expiry
[Section titled “Expiry”](#expiry)
**Expires at** (permanent links; a date and time in your browser’s time zone, empty for never) and **After expiry, send visitors to** (a URL; empty serves a “Link expired” page with status 410). A temporary link shows its fixed expiry instead of the date box. While the link is paused, a third box, **Why it is paused (only you see this)**, edits the pause note. **Save expiry** sends the block. An expired link records no clicks: taps go straight to the URL, or get the 410 page.
### Geo and language routing
[Section titled “Geo and language routing”](#geo-and-language-routing)
Permanent links only (the block is titled **Routing rules**). **Add rule** adds a block with its name in the header (placeholder `Rule 1`; recorded on each click it routes), **Countries** (two-letter codes such as `DE, AT, CH`), **Languages** (such as `de`), **From** and **Until** (scheduled routing, in your browser’s time zone), **Share of visitors (%)** (an A/B split, a whole number such as `50` or `50%`; empty for everyone), and the destinations it replaces for a matching tap: **Desktop destination**, **Mobile without the app**, **iOS deep link path**, **Android deep link path** and **Fallback URL**. Empty fields keep the link’s own. The first matching rule wins; **Move up** reorders and **Remove** deletes a rule. **Save rules** sends them all; the server’s error, such as `routing_rules[0] needs a condition: countries, languages, percent, starts_at or ends_at`, shows next to the button. Taps that match no rule are routed as usual. See [How a tap is routed](/links/how-a-tap-is-routed/#geo-and-language-routing).
### Preview
[Section titled “Preview”](#preview)
A card that mimics a social unfurl: the image (or “No image configured”), the title (or “Open in the app”), the link’s domain and the description, with the note “Actual results, including image size, may vary across platforms.”
Under the card, **Which social preview should this link use?** offers:
* **App preview** (marked **Default**), followed by the title of the app preview it would use. Choosing it clears the link’s own preview on save, so the link falls back to the app’s social preview and then to a generic “Open in the app” card.
* **Custom preview**, which reveals three fields.
| Field | What to enter | Rules |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| **Image URL** | An image address (placeholder `https://…/preview.jpg`). Hint: “JPG or PNG. Recommended size 1200 × 630 px (shown as a large card).” | http(s), or the server replies `og_image_url must be a valid http(s) URL`. |
| **Title** | Placeholder `My App`. Hint: “Recommended 30 to 60 characters. Maximum 150 characters.” | The box stops at 150 characters; the server rejects longer values with `og_title must be at most 150 characters`. |
| **Description** (optional) | Hint: “Maximum 150 characters.” | Same limit: `og_description must be at most 150 characters`. |
The card and the **App preview** line are built from the app currently selected in the **New link** form’s App dropdown, or from your oldest app when nothing is selected there. The preview people actually see when they share the link is resolved by the server from the link, then the link’s app, then a generic card; see [Social previews and QR](/links/social-previews-and-qr/).
At the bottom of the drawer, **Check the preview on each platform** asks for the link as the crawlers of Facebook, X, LinkedIn, Slack, Discord, WhatsApp, iMessage and Telegram do and shows, per platform, the title, description and thumbnail it gets with **Looks right**, **{n} issues** or **No preview**, and each issue in a sentence: no image, an image host that refuses that crawler, an image too large for the platform, an image address that is a page. It checks the saved link, so save first. See [Check the preview on each platform](/links/social-previews-and-qr/#check-the-preview-on-each-platform).
### Routing
[Section titled “Routing”](#routing)
Permanent links only.
| Field | Options |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **iOS deep link path**, **Android deep link path** | Free text, placeholder `products/42?color=black`, with the same server rules as the create form. Each box is disabled, with the placeholder `no scheme set`, while the link’s own app has no scheme for that platform, because a path is only used in a scheme URL. The drawer’s app preview and fallback default also come from the link’s own app. |
| **If the app isn’t installed** | **App stores** (marked **Default**) or **Custom URL** (placeholder `https://promo.example.com/offer`). Sets `redirect_url`. |
| **Mobile landing pages** | **iOS tap-to-store page** and **Interstitial page**, each **App setting (on)** or **App setting (off)** (the default, following the link’s app), **On for this link** or **Off for this link**. See [How a tap is routed](/links/how-a-tap-is-routed/#mobile-delivering-it). |
| **If the app can’t open the link (desktop)** | **Branded QR page**, or **App fallback URL** when the link’s app has one (marked **Default**), or **Custom URL** (placeholder `https://web.example.com/page`). Sets `fallback_url`. |
The last row sets the same `fallback_url` field, with the same labels, as the create form. The field is the desktop and no-destination tail: with no custom URL, a desktop visitor gets the app’s Fallback URL if there is one, otherwise the branded QR page.
**Save** sends the preview choice and, for permanent links, every routing field at once; an emptied field clears the stored value. Before sending, the drawer checks “Enter the custom redirect URL” and “Enter the custom fallback URL” when a Custom URL option is selected with an empty box. Success shows “Saved ✓”; failures show the server’s message.
## Pausing, archiving and deleting
[Section titled “Pausing, archiving and deleting”](#pausing-archiving-and-deleting)
| Action | What happens |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Pause** | Asks “Pause “{slug}”? Taps get a “link paused” page and nothing is recorded.” with an optional reason that only you see. Every tap then gets a “Link paused” page with status 410, bots included, and nothing is counted: no click, no install credit, no app open. **Resume** turns it back on and clears the reason. |
| **Archive** | Moves the link to the **Archived** view. It keeps working and counting; archiving only tidies the list. **Unarchive** moves it back. |
| **Delete** | Asks “Delete link “{slug}”? It stops working and leaves this list. Its clicks, installs and revenue stay in your stats, and you can restore it from Deleted.” The link answers 404, moves to the **Deleted** view, and keeps its slug: creating a link with that slug fails until you restore it or delete it permanently. Thirty days after the delete, its clicks and app opens lose their device data (IP hash, device id, user agent), the same anonymisation retention applies after `RETENTION_DAYS`; the counts stay. |
| **Restore** | On the **Deleted** view: the link works again, with its history. |
| **Delete permanently** | On the **Deleted** view; see below. |
### Delete permanently
[Section titled “Delete permanently”](#delete-permanently)
The trash icon on the **Deleted** view asks:
> Delete “{slug}” permanently?
>
> This also deletes its {n} clicks, the installs, events and app opens attributed to it, and removes them from the Overview. This cannot be undone.
For temporary links the first line reads “its clicks” without a count.
Deleting permanently is a single atomic batch that removes, in this order:
1. the events attributed to the link, by slug or through one of its installs;
2. the app opens through the link, and the referrals it converted;
3. the `first_install_id` of customers whose first install came through the link (the customer record itself stays);
4. the installs matched to one of the link’s clicks;
5. the link’s clicks;
6. the link itself.
The Overview, the Customers screen and the per-link totals drop those rows immediately. The request succeeds even for a slug that no longer exists. If the request fails, the list reloads and shows “Could not delete “{slug}”: …“.
The slug is free again as soon as the link is gone, for permanent and temporary links alike.
Self-hosting
Link URLs follow the deployment’s URL mode: `https://{username}.{root-domain}/{slug}` in subdomain mode, `{base-url}/r/{username}/{slug}` in path mode. The [Settings](/dashboard/settings/) screen shows which one applies.
# Overview
> Every KPI, chart and panel on the dashboard's Overview screen, with the exact definition the server uses for each number.
The Overview is the first screen after you sign in, at . It shows clicks, installs, conversion, revenue and attribution quality for your whole account, over one of three time ranges, and refreshes itself every 15 seconds. The header reads “Clicks, installs and conversion for your links — live.”
Every number on this page is defined below exactly as the server computes it. Several of them do not reconcile with each other by design; the last section lists those cases.
## Controls
[Section titled “Controls”](#controls)
| Control | What it does |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **7d**, **30d**, **90d** | Selects the time range. The default is **7d**. **7d** ends now and starts exactly 7 times 24 hours earlier. **30d** and **90d** are whole calendar days in UTC, today included: they start at midnight UTC 29 or 89 days ago, and their totals come from the daily rollups (see [Scheduled jobs](/self-hosting/scheduled-jobs-and-retention/#daily-rollups-both-triggers)). |
| Live refresh | Every 15 seconds the page reloads the overview, the recent events and the attribution-quality panel. The timer restarts when you change the range. |
| **Set up with your agent** | Copies the prompt `Read https://dectog.com/SKILL.md and help me get started with DecTog` to the clipboard. The label reads “Prompt copied — paste it into your agent” for two seconds. If the clipboard is blocked, a browser prompt shows the text instead. See [Automation and agents](/guides/automation-and-agents/). |
Under the title, the line “Live · refreshes every 15s · comparing against the previous {N}-day window” names the comparison window used by every delta chip.
The page shows “Loading analytics…” on first load. If the overview request fails, the whole page is replaced by a red alert with the server’s error message. If only the attribution-quality request fails, that panel says so and the rest of the page still renders. If the recent-events request fails, the Recent events card shows its empty state.
## How deltas work
[Section titled “How deltas work”](#how-deltas-work)
Each KPI card carries a chip that compares the current window with the previous window of the same length: for **7d**, the 7 days before the current 7 days.
* The chip shows the relative change, rounded to one decimal: `(current − previous) ÷ previous × 100`.
* When the previous window is 0, there is no percentage to show: the chip is hidden if the current value is above 0, and reads **0%** if it is 0 too.
* Green with ▲ and a `+` means zero or positive; red with ▼ and a `−` means negative.
* For **Conversion** the delta is the relative change of the rate, not a difference in percentage points. A rate that moves from 2.0% to 3.0% shows ▲ 50%.
## KPI cards
[Section titled “KPI cards”](#kpi-cards)
All seven cards always render. Counts use the server’s own timestamps, so an install counts in the window in which the SDK reported it, not when the device installed the app.
| Card | Caption | What the number is |
| ------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Clicks** | | Every click in the window: web taps on your links plus clicks reported by the SDK. Requests from bots never create a click. |
| **Unique visitors** | | The number of distinct hashed IP addresses among those clicks. It counts IP addresses, not devices or people: two phones on one Wi-Fi network are one visitor, and one phone that moves from Wi-Fi to cellular is two. |
| **Installs** | `{n} attributed · {n} reinstalls excluded` | Installs reported in the window, excluding reinstalls. “Attributed” is the subset the server matched to a click. Reinstalls go through the same matching as any other install but are left out of this count; the second number says how many were left out. |
| **Conversion** | `installs ÷ clicks` | Non-reinstall installs, organic ones included, divided by clicks, as a percentage with one decimal. Because organic installs count, the rate can exceed 100%, for example when the app gets installs that never came through a link. |
| **Revenue** | `tracked conversions` | Net revenue of the window: purchases minus refunds, each converted into your revenue currency (Settings) with the ECB rate of its day. Amounts whose rate is not known yet count as 0 until the daily conversion run. |
| **Leads** | `signup events` | Events whose type is `lead`. |
| **Opens** | `link re-opened the installed app` | App opens in the window: times a link opened an already installed app, as reported by the SDK. |
## Charts and cards
[Section titled “Charts and cards”](#charts-and-cards)
### Clicks over time
[Section titled “Clicks over time”](#clicks-over-time)
An area chart with one point per calendar day in UTC. Days without clicks show 0. Labels are short dates such as “Oct 3”.
The chart buckets the last N calendar days. For **30d** and **90d** that is exactly the KPI window. For **7d** the KPI window starts exactly 7 times 24 hours ago, so clicks from the partial day before the first bucket count in the **Clicks** KPI but do not appear in the chart.
### Installs by campaign
[Section titled “Installs by campaign”](#installs-by-campaign)
Bars are installs. The label above each bar is that bucket’s conversion rate; it is blank for buckets with no clicks.
* Each attributed, non-reinstall install is grouped by the `utm_campaign` query parameter of the click it was matched to. `(direct)` collects matched clicks that carried no `utm_campaign`.
* `Organic` collects installs that were not matched to any click. It has 0 clicks, so it never shows a conversion label.
* Clicks are grouped by their own `utm_campaign`, so a campaign with clicks but no installs still appears, with a bar of 0.
* Conversion per bucket is installs ÷ clicks, one decimal.
* The chart shows the top 8 buckets, sorted by installs, then clicks.
This chart reads the `utm_campaign` parameter on the URL people tapped. It does not read the **Campaign** field in a link’s Tracking section, nor `dec_campaign`. Those values are stored on the click and returned to the app, but they do not feed this chart. See [URL parameters](/links/url-parameters/).
### Installs by source
[Section titled “Installs by source”](#installs-by-source)
The same grouping, using `utm_source` instead of `utm_campaign`, with no top-8 limit.
### Devices
[Section titled “Devices”](#devices)
A donut of clicks in the window by device type, with a legend reading `{percent} · {count}`. The types are **iOS**, **Android**, **Desktop** and **Other** (a click whose device type is none of those). “Desktop” is any browser that does not identify as iPhone, iPad, iPod or Android. An iPad asking for desktop sites counts as iOS when the link’s app is set up for iOS, and as Desktop otherwise.
### Attribution quality
[Section titled “Attribution quality”](#attribution-quality)
Subtitle: “How installs were matched to clicks — deterministic referrer beats fingerprint.”
Non-reinstall installs in the window, grouped by how they were matched, as bars reading `{percent} · {count}` in this order:
| Bar | Installs counted |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Install referrer (deterministic)** | Matched through an install referrer handed over by a store (Google Play, Huawei, Samsung, Xiaomi, vivo) or by Meta’s on-device referrer. |
| **Copied link (deterministic)** | Matched through a link found on the device’s clipboard: the iOS tap-to-store page copies it, or the user pasted it. |
| **Device fingerprint (probabilistic)** | Every other matched install, including rows that record no match source. |
| **Organic** | Installs not matched to a click. With strict fraud mode on, flagged installs are stored as unmatched and land here. |
Empty state: “No installs in this window.”
Inside the card, an amber box appears when at least one non-reinstall install carries fraud flags: `{n} install(s) flagged for fraud signals`, followed by up to six chips reading `{reason} · {count}`. The reasons are the rule names `emulator`, `impossible_speed`, `install_velocity_ip`, `click_storm_ip`, `stale_referrer`, `stale_clipboard`, `stale_meta` and `click_reuse`. The headline count excludes reinstalls; the per-reason counts include them.
### Recent events
[Section titled “Recent events”](#recent-events)
Subtitle: “Leads, conversions and custom events from your app — track them with the DecTog SDK.”
The 12 most recent events of any type (`identify`, `lead`, `conversion`, `custom`), newest first. **This card ignores the time range.** Each row shows a type chip (green for `conversion`, orange for `lead`, gray otherwise), the event name (or the type when there is no name), `/{slug}` when the event is attributed to a link, and on the right either the amount in the event’s own currency (a refund with a minus sign) when the event carries revenue, or the event’s date.
Empty state: “No events yet — call `identify` / `trackConversion` in the SDK.” See [Events](/sdk/events/).
### Top devices
[Section titled “Top devices”](#top-devices)
The top 8 device models among installs in the window that reported a model, with an install count. Models are stored lowercased. A model DecTog knows shows its marketing name with the code after it (`Samsung Galaxy S23 Ultra sm-s918b`, `iPhone 17 iphone18,3`), or the name alone when the SDK already sent the marketing name (`iPhone 17`); any other shows as `{model} · {manufacturer}`. Android names come from Google Play’s supported-devices list, refreshed weekly for the models your installs report; iPhone names from a list in DecTog for the iPhone 8 to the iPhone 18 Pro Max. The count includes reinstalls; when some of them are reinstalls the row adds `(+{n} re)`.
Empty state: “No device data yet — enable the device tier in the SDK (`DectogCollection.deviceTier`).” See [Device data and privacy](/sdk/device-data-and-privacy/).
### Carriers (MCC/MNC)
[Section titled “Carriers (MCC/MNC)”](#carriers-mccmnc)
The top 8 mobile carriers among installs that reported one, as `{mcc}/{mnc}` with an install count, reinstalls included. Only the Android SDK reports carrier codes.
Empty state: “No carrier data (Android only).”
### Connectivity at install
[Section titled “Connectivity at install”](#connectivity-at-install)
The top 6 connection types reported at install, such as `wifi` or `mobile`, with install counts, reinstalls included.
Empty state: “No connectivity data yet.”
### Top referrers
[Section titled “Top referrers”](#top-referrers)
The top 8 referring sites by clicks in the window. DecTog stores only the origin of the referring page (`https://news.ycombinator.com`, never the full URL). Clicks without a referrer, and clicks referred from one of DecTog’s own domains, count as `(direct/none)`. Hover a truncated value to see it in full; the bar is relative to the sum of the rows shown.
Empty state: “No clicks in this window.”
### Top countries by clicks
[Section titled “Top countries by clicks”](#top-countries-by-clicks)
The top 10 countries by clicks in the window, shown as a flag plus the two-letter code from Cloudflare’s geolocation of the click. An unknown country shows as `??` without a flag. The card has no empty-state text.
## Breakdown
[Section titled “Breakdown”](#breakdown)
Below the charts, **Breakdown** puts every metric side by side for one dimension: clicks, installs, conversion, opens, leads, revenue and LTV (the net revenue to date of the window’s installs, per install: what an install from that source has been worth so far; app revenue only, since a website purchase has no install), per link, campaign, country, platform, OS, language, referrer, UTM value, app version, match source, routing rule (to compare A/B variants), page button (the button a visitor took on a link page) or event source (the app, the website through `dectog.js`, or a store) (the **By** menu). It covers the range selector’s window, cut into days in your browser’s time zone, and loads on its own rather than on the 15-second refresh.
* Click a value to keep only it. A chip appears above the table, “Country is GB”; click another value to add it to the chip.
* Hover a row and click **−** to leave that value out instead: “Country is not US”. A chip keeps values or leaves them out, not both; picking the other way for the same dimension replaces it.
* Click **is** or **is not** on a chip to turn it around, **×** to remove it, **Clear** to remove them all. Chips stay when you change **By**, so you can keep Germany and break it down by campaign.
* `(organic)` is what no link brought, such as installs no tap won; `(none)` is a link that carried no value for the dimension, such as a tap without a `utm_source`. A metric the dimension does not apply to shows `—`: taps have no app version, and only installs have a match source.
* An install counts under its winning tap’s values, an event under the link it is credited to (the re-opening link inside its window, else the install’s), the same rules as the [link stats](/dashboard/links/).
The table shows the 50 largest values by clicks. **Export CSV** downloads it with up to 1,000; **Raw rows…** downloads the clicks, installs, app opens or events of the window that pass the chips; the installs file also has the reinstalls and app updates the Installs column leaves out, and a chip the rows cannot have (clicks by match source) stops the download with a message. A file over 50,000 rows is written in the background when the deployment has an exports bucket: the card says so and turns into a download link, good for 7 days. See [Export raw data](/api/export/).
The numbers read the raw rows, so they reach back as far as the retention window keeps them. They can differ from the KPI cards at the window’s edges: for 30 and 90 days the cards cut days in UTC and Breakdown in your time zone, and for 7 days the cards count the last 168 hours and Breakdown seven calendar days, today included. In a browser set to UTC the 30- and 90-day totals agree. The same query is in the [Admin API](/api/admin-api/#the-analytics-query) and in the MCP `analytics` tool.
## Match confidence & referrer health
[Section titled “Match confidence & referrer health”](#match-confidence--referrer-health)
This panel reads a separate endpoint and answers a different question from the cards above: not how many installs, but how reliably they were matched. Its intro text: “How reliably installs were matched in the last {N} days: the confidence the server assigned, how often the store handed back the install referrer, and whether the fingerprint agreed with the referrer when both existed.”
Different totals
The panel counts every install in the window, including reinstalls and app updates. Its totals are higher than the Installs KPI, which excludes reinstalls.
### Tiles
[Section titled “Tiles”](#tiles)
| Tile | Value | Caption | Color rule |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| **Credited installs** | Matched installs ÷ all installs. | `{m} of {n} matched to a click` | None. |
| **Android referrer coverage** | Android installs whose store install-referrer read returned `ok`, divided by all Android installs. | `{delivered} delivered · {missing} missing of {n}`, or `no Android installs in this window`. “Missing” counts the read statuses `service_unavailable`, `timeout`, `error` and `not_available`; other statuses count in neither number. | Amber when more than 5% of Android installs are missing. |
| **Fingerprint accuracy** | Among installs matched by a deterministic signal, the share where the fingerprint, run in the background, would have picked the same click with high confidence. | `shadow match agreed on {a} of {n} referrer installs`, or `needs referrer-matched installs to measure`. | Green at 90% or above. |
| **Click → install** | `{median} median`: the median time between the server-side click time and the install report, over up to the 5,000 most recent matched installs in the window. | `p90 {x} · {n} matched installs`, or `no matched installs yet`. | None. |
Durations are formatted as `45s`, `2m 10s`, `1h 5m` or `3d 2h`. A `—` means there is nothing to measure.
### Confidence per platform
[Section titled “Confidence per platform”](#confidence-per-platform)
One bar per platform (the install’s reported OS: **iOS**, **Android**, or the raw value), sorted by installs. The label on the right reads `{percent} credited · {n} installs`, and on Android adds `· referrer {percent}`.
Each bar is split into segments by the confidence the server assigned. Hover a segment for `{Label}: {count} ({percent}) — {hint}`:
| Segment | Color | Hint | Meaning |
| ---------------- | ----------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Guaranteed** | dark green | `install referrer, copied link or paste` | A deterministic signal named the click. Credited. |
| **High** | light green | `fingerprint, corroborated and unique — credited` | The fingerprint found one clearly best click. Credited. |
| **Medium** | amber | `routed to the link, not credited` | The fingerprint found a plausible click. The app receives the deep-link route, but the install does not count as attributed. |
| **Low** | light gray | `two devices tied on different links, or contradicted — nothing` | A tie between taps on different links, or a contradiction. No route, no credit. |
| **No candidate** | near white | `no click from this network in the window` | Installs the server assigned no confidence to: no click from the same network within the window, or an install that skipped matching (app updates, replayed signals). |
The legend under the bars repeats the five labels, each with its hint on hover. What the levels mean, and the windows involved, are explained in [How attribution works](/start/how-attribution-works/).
### Install referrer reads
[Section titled “Install referrer reads”](#install-referrer-reads)
Chips reading `{status} · {count}` for every referrer-read status reported in the window, sorted by count. Hover a chip for its plain-English meaning. Statuses that indicate a failed read are amber.
| Status | Tooltip | Amber |
| ----------------------- | ------------------------- | ----- |
| `ok` | referrer delivered | |
| `organic` | store reported organic | |
| `feature_not_supported` | store too old for the API | |
| `service_unavailable` | store service unavailable | yes |
| `developer_error` | developer error | yes |
| `permission_error` | permission error | yes |
| `timeout` | timed out | yes |
| `error` | read error | yes |
| `not_available` | no store app | |
| `not_android` | iOS (no referrer API) | |
| `unknown` | not reported (older SDK) | |
Empty state: “No referrer telemetry yet.”
### Kept out of matching
[Section titled “Kept out of matching”](#kept-out-of-matching)
`{n} existing users who updated to an SDK build — recorded as app updates, never matched against the click pool.` This is the number of reports in the window whose install state was `update`: the app was already on the device before the SDK shipped, so there is no install to attribute.
### Signals on known matches
[Section titled “Signals on known matches”](#signals-on-known-matches)
A table over the installs in the window that a store referrer, pasted link or Meta referrer matched to a recorded tap: for each signal, how often that tap and the install **Agree**, **Mismatch** or are **Missing** one side. The signals worth 0 points carry `· not scored`. Two last rows carry `· network`: **Same IP address** (whether that tap came from the install’s address, which the fingerprint needs) and **Same IP version** (both IPv4 or both IPv6). Its note reads “Over {n} installs a store referrer, pasted link or Meta referrer proved: how often each signal agreed with that tap. A signal marked not scored earns points only once these numbers support it. The network rows say how often that tap came from the install’s address at all, which the fingerprint needs, and whether a change of address was a switch between IPv4 and IPv6.” Empty state: “No install in this window had a deterministic match to compare with.” See [Signals recorded for no points](/start/how-attribution-works/#signals-recorded-for-no-points).
### Rejected SDK requests
[Section titled “Rejected SDK requests”](#rejected-sdk-requests)
Calls to the `/api/v1` API that the server refused as malformed (400, 413 or 422) in the window, grouped by route and reason, most frequent first, at most 10 rows: the route (for example `POST /api/v1/events`), the error code and message, how often and when last. The server keeps the request’s SHA-256 and size, never its body, and deletes the rows after 30 days. Authentication failures and rate limits are not listed. An SDK build sending a field the server rejects shows up here before anyone notices missing data.
### SDK versions in the wild
[Section titled “SDK versions in the wild”](#sdk-versions-in-the-wild)
A table of the SDK builds that reported in the window: the build (`flutter_dectog 1.0.0`), the **New installs** registered by it, the **Events** it sent, and when it was **Last seen**. New installs show what your current release ships; events show which older builds are still running. Up to 20 builds, most recently seen first. Empty: “No SDK build reported in this window.”
### Empty and failed states
[Section titled “Empty and failed states”](#empty-and-failed-states)
* “No installs in this window.” when the window has no installs at all.
* “The attribution-quality read-out is unavailable right now.” when the endpoint fails. The rest of the Overview still renders.
## Retention
[Section titled “Retention”](#retention)
Below the match-confidence panel, **Retention** answers “did the users stay?” for the installs of the last 90 days, whatever the range selector says. Two boxes filter it: **All links** or one link (installs credited to it), and **All platforms**, **iOS** or **Android**. The card loads on its own and does not take part in the 15-second refresh.
The cohort is the first installs that carry a device id; reinstalls and app updates are left out, and a device counts once, on its first install day. A device is active on a day when the app started (the SDK’s device ping, sent once a day), a link opened it, or it sent an event that day. Days are calendar days in UTC: day 1 is the UTC day after the install’s, so an install at 23:30 that opens again at 00:30 is retained on day 1, and one that opens again at 23:50 the same day is not.
| Part | What it shows |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Day 1**, **Day 7**, **Day 30** | The share of installs at least that many days old that were active on that exact day after their install, with “{retained} of {installs} installs”; “no installs old enough yet” until one is |
| The weekly table | One row per install week (Monday to Sunday, UTC), newest first, with its installs, then for each of the next eight weeks the share active in that week, shaded darker from 10%, 30% and 60%. A week that has not started is `·`. Hover a cell for the counts. |
| The note under the table | How many installs carried no device id (the app turned collection off) and are not counted |
The figures read the raw rows, so they reach back as far as the retention window keeps them. See [Admin API](/api/admin-api/#analytics).
## Apple Search Ads
[Section titled “Apple Search Ads”](#apple-search-ads)
This card appears only once the window contains at least one install whose iOS SDK sent an AdServices token, whatever the outcome of the lookup. Its text: “iOS installs whose AdServices token Apple matched to one of your campaigns. Campaign names are not part of Apple’s payload — the ids match your Apple Ads reports.”
| Tile | Caption | Counts installs whose lookup status is | Color rule |
| ------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| **Attributed** | `installs Apple credited to a campaign` | `resolved`: Apple returned an attribution. | Green when above 0. |
| **No attribution** | `Apple found no matching record` | `no_attribution`. | None. |
| **Pending** | `waiting on Apple · {n} expired or invalid` | `pending`. The `{n}` in the caption adds up `expired` (no answer within 24 hours of the token), and `invalid` (Apple rejected the token, or none arrived). | Amber when above 0. |
Below the tiles, up to 8 rows list the campaigns behind the attributed installs: `campaign {id}` (or `campaign —` when Apple sent no id), a chip reading `view-through` for impression claims and `tap-through` otherwise, and the install count. Rows are grouped by campaign id and claim type, so one campaign can appear twice.
These counts include reinstalls. The lookup lifecycle (three inline attempts, hourly retries, the 24-hour token limit) is described in [Clicks and installs](/api/clicks-and-installs/).
## Recent installs
[Section titled “Recent installs”](#recent-installs)
The 12 newest installs, whatever the range: a chip with how each was matched (`referrer`, `clipboard`, `fingerprint`, or `organic`), the link it was credited to, the OS and device model, a fraud chip when rules fired, and the time. Clicking one opens the **install detail**:
* **Verdict**: credited link or organic, match source, confidence, reinstall, fraud flags.
* **Why**: the signal that named the click (store referrer, pasted link, Meta referrer, a late claim), or the fingerprint’s best and runner-up candidates with each signal marked agree, mismatch or missing (device model, OS version, language, screen, network), their scores, the gap between them, the window that applied and how many repeat taps were merged. Under each candidate, **Recorded, not scored** marks the signals worth 0 points the same way: OS minor version, text size, dark mode, Increase Contrast, Reduce Motion, time zone (the UTC offset), language and region, and Android WebView build. When a deterministic signal decided, a block **The tap the signal named, signal by signal** shows how that tap compares with the install, scored and unscored signals alike, and under **Network** whether it came from the install’s address and IP version, when one was recorded (see [Attribution internals](/self-hosting/attribution-internals/#the-stored-explanation)), and the fingerprint’s own pick is shown as well. When the fingerprint did not run, the reason is given.
* **Tap**: the credited (or routed) tap with its time, device and country, and any late claim that upgraded the verdict.
* **App opens** and **Events** of that install, then its **Screens**: the install’s app sessions with the screens shown in each, as on a [customer](/dashboard/customers/#screens).
* **This device’s data**: **Download everything stored (JSON)** and **Erase this device**, for a person’s request to see or delete their data; see [A request from one person](/self-hosting/privacy-and-data/#a-request-from-one-person). Erasing asks for confirmation, then shows what went (“Device erased: 1 install, 3 events…”). An install past the retention window has no device id left, and says so.
Installs recorded before match explanations existed show the verdict without the **Why** part.
## Numbers that do not add up to each other
[Section titled “Numbers that do not add up to each other”](#numbers-that-do-not-add-up-to-each-other)
These differences are by design. Knowing them saves you from chasing phantom discrepancies.
* **Installs vs the quality panel.** Reinstalls are matched like any other install; only app updates and replayed signals skip the fingerprint. They are excluded from the Installs KPI, the campaign, source and attribution-quality charts and the per-link install counts, but the Match confidence panel, Top devices, Carriers, Connectivity and Apple Search Ads include them.
* **Conversion above 100%.** Organic installs count in the numerator.
* **Revenue.** Net of refunds and converted into one currency, here and on the Links and Customers screens. A purchase in a currency whose rate has not arrived yet counts as 0 until the next daily run.
* **Campaign and source charts.** They read `utm_campaign` and `utm_source` from the tapped URL, never the link’s Tracking fields.
* **Recent events.** Always the latest 12, whatever the range.
* **Per-link numbers.** The [Links](/dashboard/links/) screen shows lifetime totals, so its columns do not match a 7-, 30- or 90-day Overview.
* **Fraud counts.** The headline excludes reinstalls; the per-reason chips include them.
* **Match sources under strict mode.** A flagged install appears as Organic here even though the server still records which link it came from. See [Apps](/dashboard/apps/).
Self-hosting
The screen is identical on a self-hosted deployment. The **Set up with your agent** prompt always points at `https://dectog.com/SKILL.md`.
# Settings
> Your account details, the account key for servers and the SDK keys for apps, how to view, copy, create, revoke and regenerate them, the link configuration of the deployment, and the audit log of every change.
The Settings screen, at , has up to ten cards: **Account**, **Account key**, **SDK keys**, **Link configuration**, **Site keys**, **Custom domain**, **Team**, **Test environment**, **Audit log** and **Flutter SDK quickstart**. Which you see depends on your [role](/dashboard/account-and-sign-in/#teams) in the account: **Account key** is for owners; **SDK keys**, **Team** and **Audit log** are hidden from viewers; only owners can change the revenue currency and the retention window. Messages appear in a banner under the title: green for a copied or regenerated key, red for an error.
## Account
[Section titled “Account”](#account)
| Field | Value |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Email** | The email you sign in with. |
| **Username / subdomain** | Your username, which is also the host of your links: `https://{username}.dectog.com/{slug}`. |
| **Sign in with Google** | Shown when the deployment has Google sign-in configured: **Connect Google** links a Google account to your sign-in (yours, in every account you work in), and the row then reads **Connected**. Connecting signs out your other sessions. See [Connect Google](/dashboard/account-and-sign-in/#connect-google). |
| **Revenue currency** | The currency every revenue total is in, EUR by default; one of the about 30 currencies the European Central Bank publishes reference rates for. Changing it converts every stored amount again. See [Revenue](/sdk/revenue/). |
| **Data retention** | How many days the account keeps raw taps, installs, events and app starts, 1 to the deployment’s maximum (400 on the hosted service). Empty keeps the maximum. **Save** applies it from the next daily run (03:17 UTC): older rows lose their personal columns, or go once the daily totals hold their day; totals stay. See [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/#anonymization-sweep-daily). |
Apart from the revenue currency, the retention window and connecting Google nothing on this card is editable. There is no way to rename the account, change its email or delete it; see [Account and sign-in](/dashboard/account-and-sign-in/).
## Account key
[Section titled “Account key”](#account-key)
The key your servers and scripts authenticate with. It has every scope, including creating, changing and deleting links, so it never goes into an app: apps get an [SDK key](#sdk-keys). It is `dtg_` followed by 32 lowercase hex characters, 36 characters in total. Only owners see this card.
The key is shown once: on the “Account created” screen after sign-up, and right after **Regenerate**. DecTog keeps only its SHA-256 and its first 8 characters, so the card shows those characters followed by dots, for example `dtg_3f9a••••••••••••••••••••••••••••`, and nobody, DecTog included, can show the key again.
| Action | What happens |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Regenerate** (red) | Asks “Regenerate the account key? Servers and scripts that use it must be updated; SDK keys are not affected.” and then replaces the key in one step. The new key is shown in full with **Copy**, and “New account key generated. Copy it now: it is not shown again. The old key no longer works.” Requests that use the old key fail with `401` (`invalid_api_key`) from that moment on. There is no grace period and no password prompt. |
| **Copy** | Shown only with a key just generated: copies it and shows “Account key copied to clipboard.” |
Hint under the key: “Full access, including creating and deleting links: use it on your servers and in scripts, never in an app. It is shown once, when created; if it is lost, Regenerate replaces it at once.”
## SDK keys
[Section titled “SDK keys”](#sdk-keys)
The keys your apps ship with, `dtg_pub_` followed by 32 hex characters. Anyone can extract a key from an app build, so an SDK key can only register installs, report clicks, events and opens, pull the attribution verdict, read links and create temporary links. Creating, changing or deleting links needs a key created with **Allow creating and changing links (links:write)**. See [Authentication](/api/overview/#authentication) for the scopes route by route.
A new account starts with one SDK key, “App key”. The card lists every key with its name, the full key, its scope chips, when it was created and last used (stamped at most once an hour), and **Copy** and **Revoke** buttons. Revoked keys stay listed, greyed out, with the date.
| Action | What happens |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Create SDK key** | Creates a key with the name in the box (default “App key”) and, when ticked, the links:write scope. An account can hold 20 active SDK keys. |
| **Copy** | Copies the full key. |
| **Revoke** | Asks for confirmation, then refuses the key: calls answer `401` with code `revoked_api_key`, at once on the edge location that handled the revoke and within a minute on the others. Release a build with a new key before you revoke the old one. |
### How the keys are stored
[Section titled “How the keys are stored”](#how-the-keys-are-stored)
Requests are authenticated with a SHA-256 hash of the key; the first characters are kept for display. The account key is stored that way only. SDK and site keys also keep an AES-256-GCM encrypted copy, under a key derived from the deployment’s `JWT_SECRET`, so this page can show them again: an SDK key ships inside your app and a site key sits in your pages, so a copy gives nothing away that a build does not. On a deployment whose `JWT_SECRET` changed since a key was created, that key shows masked; revoke it and create another. How requests are authenticated and signed is in [API overview](/api/overview/).
## Link configuration
[Section titled “Link configuration”](#link-configuration)
Read-only facts about the deployment that serves your links.
| Field | Value |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **URL mode** | **Wildcard subdomain** on the hosted service: links are `https://{username}.dectog.com/{slug}`. **Path-based (workers.dev / dev)** on deployments without a wildcard domain: links are `{base-url}/r/{username}/{slug}`. |
| **Root domain** | The domain under which link hosts live: `dectog.com` on the hosted service. |
| **Example link** | Your link URL for the slug `welcome`, in the mode above, or on your custom domain once it is active. |
Both forms are explained in [Concepts](/start/concepts/).
## Site keys
[Section titled “Site keys”](#site-keys)
Keys for [`dectog.js`](/sdk/web/) on your website. **New site key** takes a name and the sites allowed to use it (origins such as `https://shop.example.com`, one per line). Each key shows its sites, when it was last used, the `
```
A self-hosted deployment serves it from its own `PUBLIC_BASE_URL`; the snippet in Settings has the right host. The script is about 2 KB gzipped and has no dependencies. A site with a Content-Security-Policy needs that host in `script-src` and `connect-src`.
## Track what happens
[Section titled “Track what happens”](#track-what-happens)
```js
dectog.track('signup'); // a lead
dectog.track('purchase', { revenue: 49.9, currency: 'EUR', order_id: 'A-1001' });
dectog.track('newsletter', { type: 'lead' }); // any name, as a lead
dectog.track('viewed_pricing'); // a custom event
```
| Name | Recorded as |
| ---------------- | ------------------------------------------------------------------------------------------------------ |
| `purchase` | A conversion. `revenue` is required; `currency` is an ISO code (the account’s currency when left out). |
| `signup`, `lead` | A lead |
| anything else | A custom event, unless `type` says `lead` or `conversion` |
The second argument also takes `customer_id`, `metadata` (an object) and `event_id`. Pass `order_id` for every purchase: it becomes the event’s id (`order-`), so a reloaded thank-you page or a second tab never counts the order twice. Calls made before the script loaded wait in `window.dectog` when the page pushes them as `window.dectog.push(['track', 'signup'])`; the first line of the snippet makes that possible, and the snippet Settings copies has it. `push` keeps working once the script has loaded, so a page can use it for every call.
The event counts for the link of the click in `dtg_click` when the visitor landed with one (from the URL, else from the cookie), with that tap’s country, campaign and UTM values in analytics. An event without a click, or with another account’s click id, is stored without a link. Webhooks deliver leads and conversions as `event.lead` and `event.conversion` with `source: "web"`.
## Consent
[Section titled “Consent”](#consent)
`dectog.js` stores a first-party cookie, so where the law asks for consent first, it waits for it.
| `data-consent` on the script | Behaviour |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto` (default) | The script asks the server where the visitor is. In the EU and EEA, the UK and Switzerland it stores nothing and sends nothing until `dectog.consent(true)`; elsewhere it starts at once. |
| `required` | Always waits for `dectog.consent(true)`. |
| `granted` | Your page already has consent; it starts at once. |
Call `dectog.consent(true)` or `dectog.consent(false)` from your consent banner. The answer is remembered in `dtg_consent` for a year; `false` also deletes the click cookie and drops anything queued. A browser that sends Global Privacy Control (Brave does by default) counts as no until the page calls `dectog.consent(true)`. The server enforces the same rule: from a consent country, or with a `Sec-GPC: 1` header, an event without `consent: true` is answered `202` and not stored.
| Cookie | Holds | Lifetime |
| ------------- | ------------------------------------------------ | -------- |
| `dtg_click` | The click id the visitor landed with | 30 days |
| `dtg_consent` | `1` or `0` | 365 days |
| `dtg_banner` | `closed`, when the visitor closed the app banner | 30 days |
All three are first-party, `SameSite=Lax`, and `Secure` on https pages. Web events never store an IP address; the server only hashes it for the rate limit.
## The app banner
[Section titled “The app banner”](#the-app-banner)
```js
dectog.banner({ link: 'https://acme.dectog.com/app', text: 'Get the Acme app', button: 'Open' });
```
A bar at the bottom of the page (`position: 'top'` moves it) that opens a DecTog link, on iPhones, iPads and Android only unless `mobileOnly: false`. Point it at a link of yours, so the tap is a click like any other and the install that follows is attributed to it. Closing it hides it for 30 days when storing is allowed.
`dectog.clickId()` returns the click id the visitor came with, or `null`.
## The endpoints
[Section titled “The endpoints”](#the-endpoints)
`dectog.js` uses two routes; you can call them yourself.
| Route | Request | Answer |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/web-config?k=` | From an allowed origin (`Origin` header) | `200 {consent_required, cookie_days}`, cached privately for an hour |
| `POST /api/v1/web-events` | `{k, click_id?, consent?, events: [{type, event_name?, revenue?, currency?, client_event_id?, customer_id?, metadata?}]}` as JSON (sent as `text/plain`, so no preflight), at most 20 events | `200 {accepted, duplicates, rejected: [{index, error}], credited_slug}`; `202` when consent is required and missing |
Errors: `401 invalid_site_key` for an unknown or revoked key, `403 origin_not_allowed` for a page origin the key does not list (or none), `400` for a batch with no valid event (`type` is `lead`, `conversion` or `custom`; `identify` belongs to the app), `413` past 20 events, `429` past 120 requests a minute from one network. The origin check keeps other sites from using your key in a browser; it is not authentication, since a script outside a browser can send any `Origin`.
# Attribution internals
> How the Worker decides which click an install belongs to, the fingerprint scoring and confidence rules as implemented, claims, fraud rules, strict mode and the SDK state that feeds it.
This page follows `apps/worker/src/api_installs.ts`, `attribution.ts` and `fraud.ts` as they are. For the user-facing summary read [How attribution works](/start/how-attribution-works/); for request and response shapes read [Clicks and installs](/api/clicks-and-installs/).
Three routes share one verdict builder (`buildVerdict`): `POST /api/v1/app-installs` (first launch), `POST /api/v1/app-installs/:id/claim` (a signal that arrived later) and `GET /api/v1/attribution` (read-only re-pull). The first two decide; the third only reads.
## First launch, step by step
[Section titled “First launch, step by step”](#first-launch-step-by-step)
### 0. Idempotency
[Section titled “0. Idempotency”](#0-idempotency)
`install_attempt_id` (`^[A-Za-z0-9_-]{8,64}$`) is the SDK’s key for one install attempt. If the tenant already has a row with that id, the stored verdict comes back with `replayed: true` and nothing is re-evaluated. Two retries racing for the same id are caught by the unique index `idx_installs_attempt`; the loser receives the winner’s verdict.
### 1. The deterministic ladder
[Section titled “1. The deterministic ladder”](#1-the-deterministic-ladder)
The body can carry three signals. They are tried in this order:
| Signal | Body field | Parsed as |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Store referrer | `install_referrer` (with `referrer_store`: `google_play`, `huawei`, `samsung`, `xiaomi`, `vivo`; a referrer without a store counts as `google_play`) | `dtg_click=` and optionally `dtg_l=/` |
| Copied link | `clipboard_url` | Must be one of this deployment’s URLs (`/r/` path form, ``, a `*.` host, the `PUBLIC_BASE_URL` host) or a custom-scheme URL. The slug is the last path segment; `dtg_click=` is read from the query. The raw clipboard text is never stored; only `clipboard_slug`. A URL on any other host is ignored. |
| Meta referrer | `meta_referrer` (read on Android when the app has a Facebook App ID) | `dtg_click=` |
For each signal that names a click id:
1. Look the click up within the tenant. If it exists and no install holds it (`matched_click_id`), it wins.
2. If another install already holds it and that install has the same `device_id`, the SDK lost its state: the holder’s verdict is returned with `replayed: true` and no new row is written.
3. If another install on a different device holds it, the signal is a replay. The ladder notes it and **moves on to the next signal**.
4. If the click row does not exist, the Worker synthesizes one from `dtg_l=/` (the clipboard form names its slug directly). The username must be the caller’s own and the slug must exist as a permanent or temporary link. The new row keeps the store’s click id when it matches `^[A-Za-z0-9_-]{8,64}$`, gets `origin = 'synthesized_referrer'`, `source = 'web'`, `device_type` = the install’s OS family (or `android` for a store referrer with unknown OS), `ip_hash` = the install’s hash, `clicked_at` = the store’s server-side click time (`referrer_click_server_ts`, else `meta_referrer_ts`, else now) and `metadata = {"synthesized_from": ""}`. A permanent link’s `click_count` goes up by one. If the slug is unknown the signal is skipped.
When every signal is exhausted, the ladder’s outcome is `reuse` if any signal pointed at a consumed click, else `not_found` if any pointed at an unknown click, else `none`. Only `reuse` leaves a mark: the fraud flag `click_reuse`, and no fingerprint for this install.
A win sets `match_source` to `referrer` (store and Meta) or `clipboard`, `referrer_source` to the store name or `meta` (null for the clipboard) and `match_confidence` to `guaranteed`. A store referrer has no time window: a very old click still matches, and `stale_referrer` flags it when it is older than 30 days.
**Deterministic window.** A pasted link and Meta’s referrer are bounded by `deterministicWindowHours`: the app’s `deterministic_window_hours`, else `ATTRIBUTION_DETERMINISTIC_WINDOW_HOURS` (default 168, 7 days), never more than 30 days. The app is the clicked link’s app, else the install’s own. The age is measured from Meta’s `meta_referrer_ts` when present, else the click row’s `clicked_at`; a click synthesized from the tag carries the install time and is never stale. An older win is demoted (`staleSignalFlag`): it earns the flag `stale_clipboard` or `stale_meta`, consumes nothing, and the fingerprint decides as if no signal had matched. When the fingerprint neither credits nor routes, the stale click becomes `routing_click_id` with `match_confidence = 'medium'`, so the app still opens the link’s content. With strict fraud mode the stale click does not route either. A claim carrying a stale signal is `rejected` with `claim_reason: "stale_click"`.
### 2. The scored fingerprint
[Section titled “2. The scored fingerprint”](#2-the-scored-fingerprint)
Runs when the ladder found nothing, and also (as a shadow, see below) when it did. It is skipped in exactly five cases (`api_installs.ts`, `canFingerprint`): the install has no IP hash, `install_state` is `update` (an existing user updating into an SDK build must never take a click from the pool), the ladder ended in `click_reuse`, the install’s own app has `probabilistic_match = 0` (then not even the shadow runs), or the registration carries `recovered_from` (the SDK re-registering an install the server lost). A registration with `recovered_from` also ignores `clipboard_url`.
Reinstalls are not one of those cases. An install with `install_state: 'reinstall'`, or one the server classifies as a reinstall because the same tenant already has a non-reinstall install with the same `device_id`, runs the full ladder and the fingerprint like a fresh install and can be credited. It is stored with `is_reinstall = 1`, which only keeps it out of the dashboard’s Installs KPI, the per-link install counts and the other `is_reinstall = 0` analytics queries. App updates never fingerprint; reinstalls are matched but counted separately.
**Candidate pool.** One query in `runFingerprint`:
```sql
SELECT c.* FROM clicks c
WHERE c.user_id = ?1 AND c.source = 'web' AND c.device_type IN ('ios','android')
AND (?2 IS NULL OR c.device_type = ?2) -- the install's OS family
AND c.ip_hash = ?3 -- same salted IP hash
AND c.clicked_at >= ?4 -- inside the wide window
AND NOT EXISTS (SELECT 1 FROM app_installs i WHERE i.matched_click_id = c.id)
AND (?5 IS NULL OR c.link_kind != 'permanent' OR NOT EXISTS ( -- the install's app
SELECT 1 FROM links l WHERE l.user_id = c.user_id AND l.slug = c.link_slug
AND l.app_id IS NOT NULL AND l.app_id != ?5))
ORDER BY c.clicked_at DESC
LIMIT 50
```
`?5` is the install’s own app. A click on a link bound to a different app of the account is no candidate, so one account’s two apps cannot take each other’s installs. Clicks on temporary links and on links bound to no app stay candidates for every app. When the install’s app is unknown (a package name that matches none of several apps) the pool is not scoped.
So the pool is the 50 newest web clicks (recorded by the redirect engine or synthesized; an in-app open that flipped a row to `source = 'mobile_app'` is out) from the same hashed IP, on the same device family, inside the wide window, not yet credited to any install. A routing-only assignment does not consume a click.
**Windows.** `fingerprintWindows` computes `wideHours = clamp(ATTRIBUTION_WINDOW_HOURS, 1, 720)` (default 24) and `lowHours = min(app override or ATTRIBUTION_LOW_ENTROPY_WINDOW_HOURS, wideHours)` (default 2). The app override is `low_entropy_window_hours` of the install’s own app: the app whose Android package or iOS bundle ID equals `package_name`, or the only app of a one-app account (`appOfInstall` in `appscope.ts`). With no such app the deployment default applies. A candidate keeps the wide window only when the install is not on cellular and its device model or its screen profile could be compared with the install’s, that is, present on both sides (`lowEntropy` false). Phone browsers send no model, so on iOS the screen profile the interstitial beacon records is what earns the wide window. Every other candidate must be younger than `lowHours`.
**Scoring.** `scoreCandidate` adds points per signal. A signal missing on either side scores 0; absence is never evidence.
| Signal | Install side | Click side | Agree | Disagree |
| ----------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | --------------------------- |
| Device model | `device_model` (lower-cased) | `device_model` from `Sec-CH-UA-Model` or the WebView `Build/` token | +40 | Candidate excluded entirely |
| OS major | `os_major` parsed from `os_version` | `os_major` parsed from `os` (platform-version hint on Android; Safari’s `Version/` token on iOS, none for an 18.6 or later OS token without one) | +15 | −30 |
| Language | `lang` from `language` | `lang` from `Accept-Language` | +10 | −15 |
| Screen short side | `min(screen_width, screen_height)` in physical px | `min(screen_width, screen_height) × pixel_ratio` from the interstitial beacon | +15 when within 3 % | −40 |
| Network | `asn` | `asn` | +10 | 0 |
There is no CPU signal. No phone browser sends an architecture (iOS Safari sends no client hints; Android Chrome answers `Sec-CH-UA-Arch` with an empty string), so the redirect no longer asks for it. The install’s `cpu_type` and `cpu_family` are still stored, for the `emulator` rule.
**Unscored signals.** Eight more values are compared per candidate and worth 0 points, so they never change a score, a rank or a confidence. They are recorded to measure how often each agrees on the right click (see `signal_check` below); none earns points until those numbers support it.
| Signal | Install side | Click side |
| --------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| iOS minor version | `os_minor` parsed from `os_version` | `os_minor` from Safari’s `Version/` token; compared only when both sides carry the same OS major |
| Text size | `text_size`: body text points for the text-size setting (iOS only) | `text_size`: the computed px of `font: -apple-system-body`, which the beacon sends only when the browser resolves the iOS system text style |
| Dark mode | `dark_mode` | `dark_mode` from `prefers-color-scheme` |
| Increase Contrast | `high_contrast` (iOS only) | `high_contrast` from `prefers-contrast`, in any browser; an Android install has nothing to compare it with |
| Reduce Motion | `reduce_motion` | `reduce_motion` from `prefers-reduced-motion` |
| UTC offset | `utc_offset_min` | `utc_offset_min` from the page’s clock |
| Language and region | `language` (`en_SA`), compared as language and region (`en-sa`) | the first tag of `Accept-Language` (`en-SA`); compared only when both sides carry a region, since the scored language signal covers the rest |
| Android WebView build | the `Build/` id and full `Chrome/` version in `web_user_agent`, the WebView user agent the SDK sends | the same two tokens in the tap’s user agent, only when it is a WebView’s (`; wv)`): a tap from an in-app browser such as Instagram’s or Facebook’s, which renders with the system WebView. Chrome’s own user agent is reduced to `Android 10; K` and a major-only version, so a Chrome tap is `missing` |
A value present on both sides is `agree` when the two are exactly equal and `mismatch` otherwise, with no tolerance: a UTC offset or a text size that differs by any amount is a mismatch. The language and the WebView user agent were already stored on both sides; comparing them reads nothing new from the device. A value absent on either side is `missing`, and so is `os_minor` unless both sides carry the same OS major. The verdicts are stored under `unscored` in the explanation below. The settings come from the device tier on the install side and from the beacon on the click side; the UTC offset is sent outside the device tier, like `timezone`.
**Repeat taps.** Before ranking, candidates with the same signature collapse into the newest of them: same IP hash and device family (the query already requires both), same device model, OS major, language and screen short side. As far as the evidence goes such candidates are one phone tapping twice (a double tap, a pull-to-refresh, the same message tapped again), and without collapsing they would tie with each other and nobody would be credited. Only taps that carry a device model or a screen profile collapse: two taps that agree on nothing more than OS major and language are as likely two people on one network, so they keep competing and can still tie. The install stores `candidate_count` as the number of distinct candidates and `collapsed_from` as the number of click rows merged into the one the verdict used (null when nothing merged). The trade-off: two phones of the same model, OS, language and screen behind one address now look like one phone, and the newer tap is credited instead of nobody. The click rows themselves are kept; only the matching treats them as one.
Candidates are sorted by score, highest first; the SQL order (newest first) and a stable sort mean recency only breaks ties.
**Ties on one link.** After sorting, candidates less than 10 points behind the best one that carry the same link, the same `dec_*` params (deep link, campaign and the rest), the same UTM tags, and the same referrer, routing rule, browser and place merge into the best one and add to its `collapsed_from`. Whichever of them were credited, the install would report the same link, campaign and route, so their tie is no ambiguity. This covers what the signature collapse cannot: an iPhone that tapped a link twice in Safari carries neither a device model nor a screen profile, so its two taps never collapse, and before this they tied with each other and the install ended organic, while a single tap was credited. The cost: when two people on one network tapped the same link and both install, each install may hold the other person’s click row; the link, campaign and route they report are the same either way. A tie between different links, or between clicks of one link that carry different params, still credits nobody.
**Confidence.** `confidenceOf`, verbatim:
```ts
export function confidenceOf(ranked: CandidateScore[]): MatchConfidence | null {
if (ranked.length === 0) return null;
const top = ranked[0];
const second = ranked.length > 1 ? ranked[1].score : null;
const unique = second == null || top.score - second >= 10;
if (!unique || top.score < 0) return 'low';
if (top.score >= 15 || top.modelMatch || (top.screenAgree && top.osMajorMatch)) return 'high';
return 'medium';
}
```
In words: no candidates gives null; a top candidate that is not unique (gap under 10 to the runner-up) or has a negative score is `low`; a unique top with a score of 15 or more, a model match, or screen agreement plus OS-major agreement is `high`; a unique top scoring 0 to 14 is `medium`.
| Ranked scores | Result | Why |
| ------------------------------------------ | ------ | -------------------------------------------- |
| none | null | No candidate inside the window |
| one candidate, 0 | medium | Nothing agreed and nothing contradicted |
| one candidate, OS +15 and language −15 = 0 | medium | Contradicted, but not below zero |
| one candidate, language +10 | medium | Agreement too weak to corroborate |
| one candidate, OS +15 | high | An OS-major agreement alone clears 15 |
| one candidate, model +40 | high | Model match |
| 55 and 40 | high | Unique (gap 15) |
| 15 and 10, different links | low | Tie at the top (gap 5) |
| 15 and 15, same link and click | high | The tie merges into one candidate scoring 15 |
| one candidate, −30 | low | Contradiction outweighed the agreements |
**Outcome.** `candidate_count` is stored whenever the fingerprint ran; `match_score` and `match_confidence` from the fingerprint are stored only when it was the deciding step.
| Confidence | Install | Click |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `high` | `matched = 1`, `match_source = 'fingerprint'`, `matched_click_id` set | Consumed |
| `medium` | `matched = 0`, `routing_click_id` = the click; the verdict’s `routing` object names its link, deep link and click params with `confidence: "medium"` | Not consumed; still a candidate for the next install |
| `low` | `matched = 0`, `match_confidence = 'low'` | Untouched |
| null | `matched = 0`, `match_confidence` null | |
Credited or not, a matched click’s link must still exist; if the link was deleted the install becomes organic with `match_source` null.
### 3. Shadow match
[Section titled “3. Shadow match”](#3-shadow-match)
When a deterministic signal decided, the fingerprint still runs and writes what it would have picked: `fp_shadow_click_id` and `fp_shadow_agree` (`1` only when the fingerprint reached `high` on the same click, else `0`). The dashboard’s “fingerprint accuracy” figure is the share of `fp_shadow_agree = 1` over these labelled rows. See [Overview](/dashboard/overview/).
### 4. Fraud rules and strict mode
[Section titled “4. Fraud rules and strict mode”](#4-fraud-rules-and-strict-mode)
`evaluateFraudFlags` runs after the match and before the insert. Flags are stored as a JSON array in `fraud_flags` (null when clean), returned on the verdict and sent to the webhook. Flags are only ever merged; a later claim never removes one.
| Flag | Fires when |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `emulator` | `is_physical_device` is `false`; or `device_model` matches `sdk`, `emulator`, `simulator`, `genymotion`, `virtual`, `gphone`, `pdv` or `avd` as a word; or an Android install with `device_kind` null or `phone` reports an x86 CPU |
| `impossible_speed` | The winning signal’s click time is less than 10 seconds before the install request. The click time is `referrer_click_server_ts` (Google’s clock), else `meta_referrer_ts`, else the click row’s `clicked_at`; device-clock fields are never used. |
| `install_velocity_ip` | At least 5 installs (25 when the install reports `cellular`) from the same hashed IP in the last 24 hours. The count runs before the row is inserted, so on first launch it fires on the 6th (or 26th) install; on a claim the row already exists, so on the 5th (or 25th). Skipped when the install has no IP hash. |
| `click_storm_ip` | The matched click’s hashed IP produced more than 100 clicks in the hour up to that click |
| `stale_referrer` | `match_source` is `referrer` or `clipboard` and the click is older than 30 days |
| `stale_clipboard` | A pasted link’s click is older than the deterministic window (7 days by default, per app). The install is routed to the link but not credited. |
| `stale_meta` | The same for Meta’s install referrer, timed by Meta’s own click timestamp |
| `click_reuse` | A deterministic signal named a click that another device’s install already holds |
With `strict_fraud_mode = 1` on the app the verdict concerns (the app of the credited or routed link; else the install’s own app, the one whose Android package or iOS bundle ID equals the `package_name` the SDK sends (on an account with one app, that app); else the oldest app), any flag makes the stored row `matched = 0` and `match_source = NULL` while `matched_click_id`, `matched_slug`, `referrer_source` and `match_confidence` are kept as evidence. Consequences:
* The click stays consumed, so a replayed signal cannot free it for the next install.
* The API verdict says `matched: false`, `type: "organic"`, `match_source: null`, but still `referrer_source` and `match_confidence` (for example `guaranteed`); `username`, `slug` and `link` are absent and `routing` is null.
* The webhook payload carries `matched: false` with `slug`, `link`, `clicked_at`, `click_params` and `deep_link` all `null`, so a consumer cannot credit it by accident.
* A later claim finds `matched_click_id` set with `match_source` not `fingerprint` and answers `claim: "unchanged"`, `claim_reason: "already_deterministic"`: a strict-zeroed verdict is final.
* Overview counts the install as organic.
With strict mode off (the default) flags are annotations and the attribution stands.
### Same-device reinstall routing
[Section titled “Same-device reinstall routing”](#same-device-reinstall-routing)
When the ladder and the fingerprint found nothing to credit or route, a reinstall (`is_reinstall = 1`) with a `device_id` gets the click its device’s latest credited install consumed as `routing_click_id` with `match_confidence = 'medium'`, provided that tap is younger than the deterministic window of its link’s app (`deterministic_window_hours`, default 168). The click stays credited to the first install; `match_details.reinstall_routing` names it.
### 5. After the insert
[Section titled “5. After the insert”](#5-after-the-insert)
The row and, when `ad_id` was sent, an `ad_identifiers` row are written in one batch. Then, in `waitUntil`: the Apple Search Ads round-trip when `asa_token` was sent (three attempts five seconds apart, stopping at the first `5xx` or network error; `invalid` on 400; `pending` otherwise, for the cron), and the `app.installed` webhook event for the app the verdict concerns. The response is the verdict. `apple_search_ads` is always present (null when no token was ever sent); `link` is present only for a permanent link, a temporary-link match has `username` and `slug` with `custom_params` null.
## The stored explanation
[Section titled “The stored explanation”](#the-stored-explanation)
Every install row keeps `match_details`, a JSON object the dashboard’s install detail shows (`GET /api/admin/installs/:id`):
| Field | Meaning |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `method` | `referrer`, `clipboard`, `meta`, `fingerprint`, or null for an uncredited install |
| `signal`, `referrer_source`, `click_id`, `synthesized` | The deterministic signal that named a click, when one did |
| `stale` | `stale_clipboard` or `stale_meta` when that signal was older than the window |
| `fingerprint_skipped` | `no_ip`, `update`, `click_reuse`, `probabilistic_off` or `recovery` when the fingerprint did not run |
| `candidates`, `collapsed_from`, `window_hours`, `gap`, `confidence` | The fingerprint’s pool after collapsing, the merged taps, the window of the best candidate, its lead over the runner-up |
| `best`, `runner_up` | `{click_id, slug, clicked_at, score, signals, unscored, low_entropy}`; `signals` marks `device_model`, `os_major`, `lang`, `screen` and `asn` as `agree`, `mismatch` or `missing`; `unscored` marks `os_minor`, `text_size`, `dark_mode`, `high_contrast`, `reduce_motion`, `utc_offset`, `locale` and `webview` the same way, for 0 points |
| `shadow` | The same fingerprint fields when a deterministic signal decided |
| `signal_check` | `{click_id, signals, unscored, network}` when a deterministic signal (store referrer, pasted link, Meta) matched a recorded click: how the click known to be right compares with the install, signal by signal. `network` holds `same_ip` (the tap came from the install’s hashed address, the only taps the fingerprint can see) and `ip_version` (both IPv4 or both IPv6; `missing` when a raw address is not stored), marked the same way. Not written when the click was synthesized from the referrer tag (there is no browser side to compare), or on a first launch where the fingerprint does not run (the cases under step 2, Probabilistic match off among them). A claim that upgrades the verdict writes its own, unless its click was synthesized or the install’s app has Probabilistic match off. `GET /api/admin/attribution-quality` counts these verdicts per signal. |
| `claim_source`, `before_claim` | After a late claim upgraded the verdict: the claim’s source and the explanation it replaced |
## Worked examples
[Section titled “Worked examples”](#worked-examples)
Each row is a golden scenario the worker’s test suite runs end to end on every change (`apps/worker/test/fixtures/attribution-cases.json` in the server repository): taps go through the redirect engine, installs and claims through the signed API. “Same network” means the install came from the tap’s IP address. A row marked as a known defect documents today’s behaviour; the plan item named there changes it.
| Scenario | What happens | Verdict |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Android store referrer names the click | tap on android (Pixel 9); install i1 (store referrer; other network) | i1: `matched: true`, `match_source: referrer`, `match_confidence: guaranteed` |
| A self-describing referrer stands in for a click row that never landed | no recorded tap; install i1 (store referrer) | i1: `matched: true`, `match_source: referrer`, `match_confidence: guaranteed` |
| iOS pasteboard link with the click id | tap on ios, no model hint; install i1 (pasteboard link; other network) | i1: `matched: true`, `match_source: clipboard`, `match_confidence: guaranteed` |
| Fingerprint with a device-model match is credited | tap on android (Pixel 9); install i1 (no signal; same network) | i1: `matched: true`, `match_source: fingerprint`, `match_confidence: high` |
| Fingerprint without corroboration routes but does not credit | tap on android, no model hint; install i1 (no signal; same network) | i1: `matched: false`, `match_source: null`, `match_confidence: medium`, routing routes to promo |
| No click at all is organic | install i1 (no signal; other network) | i1: `matched: false`, `match_source: null`, routing none |
| One install per click: a second device replaying the referrer gets nothing | tap on android (Pixel 9); install i1 (store referrer; other network); install i2 (store referrer; other network) | i1: `matched: true`, `match_source: referrer` i2: `matched: false` |
| A retried registration replays the stored verdict | tap on android (Pixel 9); install i1 (store referrer, attempt id; other network); install i2 (store referrer, attempt id; other network) | i1: `matched: true` i2: `matched: true`, `replayed: true` |
| A referrer delivered on a later launch upgrades an organic install | tap on android (Pixel 9); install i1 (no signal; other network); later claim on i1 (late_referrer) | i1: `matched: false` claim i1: `claim: upgraded`, `matched: true`, `match_source: referrer` |
| An app update never runs the fingerprint | tap on android (Pixel 9); install i1 (app update; same network) | i1: `matched: false`, routing none |
| Two taps from one phone collapse into one candidate; the newest is credited | tap on android (Pixel 9); tap on android (Pixel 9); install i1 (no signal; same network) | i1: `matched: true`, `match_source: fingerprint`, `match_confidence: high`, credited to the second tap; `collapsed_from: 2` |
| Taps from two different phones on one network with different campaigns still tie | tap on android (Pixel 9, `dec_campaign=spring`); tap on android (Pixel 8, `dec_campaign=summer`); install i1 (no signal, no model; same network) | i1: `matched: false`, `match_confidence: low`, routing none; `candidate_count: 2` |
| Taps on one link from two phones on one network give the same verdict either way, so they merge | tap on android (Pixel 9); tap on android (Pixel 8); install i1 (no signal, no model; same network) | i1: `matched: false`, `match_confidence: medium`, routing routes to promo; `candidate_count: 1`, `collapsed_from: 2` |
| Two taps on one link from an iPhone with neither a model nor a screen profile merge; the newest is credited | tap on ios, no model hint, no beacon; the same again; install i1 (no signal, iOS 18.0; same network) | i1: `matched: true`, `match_source: fingerprint`, `match_confidence: high`, credited to the second tap; `collapsed_from: 2` |
| Safari on iOS 26 and later reports a frozen OS version; its Version token has the real one | tap on ios (OS token 18.6, `Version/26.0`), no model hint; install i1 (no signal, iOS 26.0; same network) | i1: `matched: true`, `match_source: fingerprint`, `match_confidence: high` |
| iPad Safari asks for desktop sites as a Mac; with touch reported, the tap is an iOS tap | tap from Safari’s Mac user agent (`Version/27.0`) with `_ipad=1`; install i1 (no signal, iOS 27.0; same network) | i1: `matched: true`, `match_source: fingerprint`, `match_confidence: high` |
| Settings are recorded, not scored: two taps that differ only in their settings still merge, and the newer is credited (known defect: settings not scored yet) | tap on ios with the beacon (text size 23, dark); tap on ios with the beacon (text size 17, light); install i1 (no signal, text size 23, dark; same network) | i1: `matched: true`, `match_source: fingerprint`, `match_confidence: high`, credited to the second tap; `collapsed_from: 2` |
| An iOS tap whose page recorded the screen keeps the 24-hour window: an install eight hours later is still credited | tap on ios, no model hint, with the beacon’s screen profile, 8 hours earlier; install i1 (no signal, same screen; same network) | i1: `matched: true`, `match_source: fingerprint`, `match_confidence: high`, `candidate_count: 1` |
| An iOS tap with neither a device model nor a screen profile gets the 2-hour window: an install three hours later is organic | tap on ios, no model hint, no beacon, 3 hours earlier; install i1 (no signal; same network) | i1: `matched: false`, `match_source: null`, routing none, `candidate_count: 0` |
## Claims
[Section titled “Claims”](#claims)
`POST /api/v1/app-installs/:id/claim` carries any subset of the signal fields above plus `attempt`, `asa_token` and `claim_source` (`late_referrer`, `paste_control` or `clipboard`; validated with a 400, and stored as `claim_source` on the row when the claim upgrades it, so a paste control can be told apart from an automatic pasteboard read). The install must belong to the caller (404 otherwise). Every call refreshes the stored telemetry (a later, more informative referrer read overwrites; absent fields keep their value), bumps `attempt` to the maximum seen, and sets `clipboard_slug` if it was empty.
Then, in order:
1. Already deterministic (`matched_click_id` set and `match_source` not `fingerprint`, which includes strict-zeroed rows): `claim: "unchanged"`, `claim_reason: "already_deterministic"`.
2. The ladder runs again with the row’s own id excluded as a holder. The fingerprint **never** runs on a claim.
3. `replay` (another row of the same device holds the click): the holder’s verdict with `replayed: true`, `claim: "unchanged"`; the SDK adopts that install id.
4. `reuse`: `click_reuse` is merged into the flags, `claim: "rejected"`, `claim_reason: "click_reuse"`.
5. `not_found` / `none`: `claim: "rejected"` with `click_not_found` / `no_signal`.
6. A pasted link or Meta referrer older than the deterministic window: its flag (`stale_clipboard` / `stale_meta`) is merged, `claim: "rejected"`, `claim_reason: "stale_click"`.
7. Otherwise the deterministic click wins over whatever the fingerprint had guessed (the old click is released by no longer being referenced) and over organic: `matched = 1`, `match_source`, `referrer_source`, `match_confidence = 'guaranteed'`, `routing_click_id = NULL`, `claimed_at = now`. Fraud rules are re-evaluated and merged, strict mode applies as above, and the webhook fires with `app.install_updated`. Response: `claim: "upgraded"`.
An `asa_token` on a claim restarts the Apple round-trip unless the row is already `resolved` or `no_attribution`; `invalid` and `expired` rows accept a new token.
## Apple Search Ads
[Section titled “Apple Search Ads”](#apple-search-ads)
Independent of the click ladder: the SDK sends the AdServices token, the Worker exchanges it with Apple and stores the result on the install (`asa_status`, `asa_attribution`). An attributed resolution re-fires the webhook. Statuses, retries and the 24-hour expiry are on [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/); the SDK side is on [Deterministic attribution](/sdk/deterministic-attribution/).
## What the SDK keeps on the device
[Section titled “What the SDK keeps on the device”](#what-the-sdk-keeps-on-the-device)
The Worker’s idempotency and claim logic depends on SDK state the server never sees directly.
* **Sentinel v2**: `.dectog_install.json` (`lib/src/services/install_state_store.dart`) in a backup-excluded directory (Android `noBackupFilesDir`, iOS `NSURLIsExcludedFromBackupKey`). Fields: `v` (2), `status` (`pending`, `tracked`, `failed`, `abandoned`), `install_attempt_id`, `attempt`, `created_at`, `attempted_at`, `tracked_at`, `last_attempt_at`, `last_error`, `install_id`, `package_first_install_ts`, `referrer_claimed`, `referrer_status`, `referrer_launches`, `plugin_version`. A sentinel whose `package_first_install_ts` differs from the package’s current first-install time is a restored backup and is treated as absent. “Claimed” is written only after the server answered 200.
* **Method channel** `dectog_device_identifiers`: the native readers behind the signals. Android: the Play Install Referrer reader (three attempts, server-side timestamps), the Huawei, Samsung, Xiaomi and vivo readers, the no-backup directory, package install info, launch info (referrer, launched-from-history) and the pasteboard helpers. iOS: install dates (file system and Keychain), backup exclusion, the AdServices token, the pasteboard helpers and the `UIPasteControl` platform view. Every method returns null or false on failure, and an unknown method degrades gracefully on the Dart side.
See [Install attribution](/sdk/install-attribution/) for how the app consumes the verdict and [Device data and privacy](/sdk/device-data-and-privacy/) for what the device tier sends.
# Configuration reference
> Every Worker variable, secret and binding with its development default, production value and effect, plus the per-app options and the constants that cannot be changed without editing code.
The Worker reads its configuration from the `Env` type in `apps/worker/src/types.ts`. Values come from four places:
| Source | Used for | Where |
| ------------------------------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Top-level `vars` in `apps/worker/wrangler.jsonc` | Local development defaults | `npm run dev` |
| `env.production.vars` in the same file | Production | `wrangler deploy --env production` |
| `.dev.vars` (never committed) | Local secrets | Read by `wrangler dev`; copy `.dev.vars.example` |
| `wrangler secret put --env production` | Production secrets | Stored by Cloudflare, listed with `wrangler secret list --env production`, removed with `wrangler secret delete` |
A one-off override for a single run uses `wrangler dev --var NAME:value` (this is how `npm run dev:android` swaps `PUBLIC_BASE_URL`). The test suite injects its own values through `apps/worker/vitest.config.ts` (`JWT_SECRET=test-jwt-secret`, `IP_SALT=test-ip-salt`, `APP_HOST=app.test.local`, `UNKNOWN_HOST_REDIRECT=https://fallback.example.com/`, `ASA_RETRY_DELAY_MS=20`, `EXPORT_STREAM_MAX_ROWS=50`, and a local `EXPORTS` bucket).
## Variables
[Section titled “Variables”](#variables)
“Dev” is the top-level value in `wrangler.jsonc`; “Production” is the value in `env.production` as shipped (your own domain replaces `dectog.com`). “Unset” means the code default applies.
| Variable | Dev | Production | Effect |
| ---------------------------------------- | ----------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ROOT_DOMAIN` | `localhost:8787` | `dectog.com` | Tenant hosts are `.`; the port is ignored and `*.localhost` qualifies, so `demo.localhost:8787` works locally. Also: without an assets binding, the apex 301 of every page path to `SITE_URL`; recognizing the deployment’s own hosts as self-referrers and as valid clipboard URLs, and subdomain resolution of the well-known files (skipped while the value starts with `localhost`). |
| `LINK_URL_MODE` | `path` | `subdomain` | `subdomain` builds `https://./`, and only when `ROOT_DOMAIN` does not start with `localhost`. Any other value, or a localhost root, builds `/r//`. Both URL forms are always served regardless of the mode; this only changes the URLs the API and dashboard print. |
| `PUBLIC_BASE_URL` | `http://localhost:8787` | `https://app.dectog.com` | Base of path-mode link URLs (`http://localhost:8787` when unset). Its host counts as the deployment’s own for clipboard-URL validation and self-referrer suppression. Shown in Settings. |
| `WEB_URL` | `http://localhost:3000` | `https://app.dectog.com` | The dashboard origin: the only allowed CORS origin for `/api/admin/*` (with credentials); where a known tenant’s root 302s; where the site host sends the dashboard pages (301); the base of password-reset and invite links and of the Google redirect URI `/api/auth/google/callback`. |
| `SITE_URL` | `http://localhost:3000` | `https://dectog.com` | The public site’s origin: the origin printed in `/robots.txt`, `/sitemap.xml`, a blog post’s `og:url` and canonical link; where the dashboard host sends the site pages (301) and where `/` on the apex and on unknown hosts 302s without an assets binding. Its host serves the site pages (`/`, `/about`, `/blog`, `/contact`, `/SKILL.md`, `/llms.txt`) from the `ASSETS` binding. May equal `WEB_URL`: then one host serves every page. See [Well-known, SEO and routing](/api/well-known-seo-and-routing/#host-and-path-routing). |
| `APP_HOST` | unset | `app.dectog.com` | Host whose non-Worker paths are served from the `ASSETS` binding (Worker paths: `/api/`, `/r/`, `/.well-known/`, `/_/`, `/healthz`, `/robots.txt`, `/sitemap.xml`). On this host `/login` 302s to `/dashboard` for a signed-in session, `/` 302s to `/dashboard` or `/login`, the site pages 301 to `SITE_URL` and the `/?user=` link form is disabled. Unset: no asset serving at all. |
| `UNKNOWN_HOST_REDIRECT` | unset | `https://dectog.com/` | 302 target for an unknown tenant host and for an unknown username in any URL form. Unset: JSON `404 {"error":{"code":"not_found","message":"Not found"}}` on a bare host, the HTML “Link not found” page on a link path. |
| `ENVIRONMENT` | unset | `production` | `production` makes the bootstrap token mandatory, disables the demo seed and makes `IP_SALT` mandatory. Nothing else reads it. See [Deploy](/self-hosting/deploy/). |
| `RETENTION_DAYS` | unset (400) | `400` | Age after which the daily job anonymizes personal data. `0`, blank or non-numeric means 400. Negative values are not rejected and would anonymize every row at the next run. See [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/). |
| `SDK_AUTH_CACHE_SECONDS` | unset (60) | unset (60) | How long an isolate keeps an SDK key and its account after looking them up, so an SDK call spends its database round trips on its own data. `0` looks them up on every call. A revoked key, or an account changed or erased, can go unseen by the other isolates for that long; the daily sweep deletes the events an erasure missed in that minute. See [Privacy and data](/self-hosting/privacy-and-data/#a-request-from-one-person). |
| `ATTRIBUTION_WINDOW_HOURS` | unset (24) | `24` | The wide fingerprint window for candidates with a device model or a screen profile to compare with the install, clamped to 1 to 720. Deterministic referrer matching is not windowed. |
| `ATTRIBUTION_LOW_ENTROPY_WINDOW_HOURS` | unset (2) | unset (2) | The short window for candidates with neither a device model nor a screen profile to compare, and for cellular installs, minimum 1. The effective value is `min(app override or this, ATTRIBUTION_WINDOW_HOURS)`. See [Attribution internals](/self-hosting/attribution-internals/). |
| `ATTRIBUTION_DETERMINISTIC_WINDOW_HOURS` | unset (168) | unset (168) | How long a pasted link or a Meta referrer earns credit, in hours, capped at 720. Older ones route without credit (`stale_clipboard`, `stale_meta`). Per-app override: `deterministic_window_hours`. See [Attribution internals](/self-hosting/attribution-internals/). |
| `REENGAGEMENT_WINDOW_HOURS` | unset (168) | unset (168) | Hours after a link re-opened the app during which events count for that link, capped at 720. Per-app override: `reengagement_window_hours`. |
| `ASA_RETRY_DELAY_MS` | unset (5000) | unset (5000) | Pause between the three inline Apple Search Ads attempts; any non-negative number. |
| `BRAND_NAME` | unset | unset | The product name on link pages (“This … link does not exist”, “Go to …”) and in the password-reset email. Unset: DecTog. |
| `MIN_SDK_VERSION` | unset | unset | The oldest build each SDK may be, as `flutter_dectog/1.1.0, react-native-dectog/1.0.2`; a bare version such as `1.1.0` applies to every SDK without an entry of its own. Identified SDK builds older than theirs get `400` with code `sdk_too_old`. An SDK the value does not name, and every version when it is unset, are accepted. See [SDK identity](/api/overview/#sdk-identity). |
| `STORE_RAW_IP` | unset | unset | Unset, the raw client IP is stored next to its hash on clicks, installs and events, for the Customers page’s IP rows. `"0"` stops storing it. Matching uses only the salted hash either way. See [Privacy and data](/self-hosting/privacy-and-data/). |
| `OPEN_SIGNUPS` | unset (open) | unset (open) | `"0"` closes public signup: `POST /api/admin/signup` answers `403 Signup is currently closed`, the login page hides the signup tab (via `GET /api/auth/config`), and Google sign-in refuses to create new accounts (`signup_closed`). Existing accounts, linking an existing account to Google, bootstrap, and joining a team from an invite (which creates no account) are unaffected. |
| `CUSTOM_DOMAIN_TARGET` | unset | unset | The hostname customers point their custom link domain’s CNAME at (the Cloudflare for SaaS fallback origin), e.g. `domains.dectog.com`. With the secrets `CF_API_TOKEN` and `CF_ZONE_ID` it turns custom domains on. See [Custom domains](/self-hosting/custom-domains/). |
| `EXPORT_STREAM_MAX_ROWS` | unset (50000) | unset (50000) | Rows a CSV export from the dashboard streams at once. Past it the export becomes a job that writes to the `EXPORTS` bucket, or is refused without one. See [Export raw data](/api/export/#larger-exports). |
| `EMAIL_FROM` | unset | unset | Sender of the password-reset email; the code default is `DecTog ` (the comment in `types.ts` naming `noreply@dectog.com` is stale). Set it to a sender verified in your Resend account. The `Reply-To` is hard-coded to `hello@dectog.com`. |
Bindings:
| Binding | Dev | Production | Effect |
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DB` (D1) | database `dectog`, id `local-dev`, local state under `.wrangler/state` | database `dectog`, your id | The only data store. `migrations_dir` is `migrations` in both. |
| `ASSETS` (static assets) | none | `../web/out`, `run_worker_first: true`, `html_handling: auto-trailing-slash` | The dashboard and marketing export built by `npm run build:static` in `apps/web`. |
| `EXPORTS` (R2, optional) | none | none until you add it | Holds CSV exports larger than `EXPORT_STREAM_MAX_ROWS`, each deleted after 7 days. Create a bucket (`wrangler r2 bucket create dectog-exports`) and add `"r2_buckets": [{ "binding": "EXPORTS", "bucket_name": "dectog-exports" }]` to `env.production` in `wrangler.jsonc`; on a deployment made with `npm run setup`, pass `--exports-bucket dectog-exports` to it instead. Without it a larger export answers `422 export_too_large`. |
| `ASA_FETCHER`, `WEBHOOK_FETCHER`, `OG_FETCHER`, `GOOGLE_FETCHER`, `CF_API_FETCHER` | test only | never | Service bindings the test suite uses to stand in for Apple, your webhook, fetched social-preview pages, Google’s token endpoint and Cloudflare’s custom hostnames and DNS-over-HTTPS APIs. In production the Worker uses global `fetch`. |
## Secrets
[Section titled “Secrets”](#secrets)
| Secret | Required | When it is missing | When it is rotated |
| ------------------------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `JWT_SECRET` | Yes | Dashboard sessions cannot be signed, so nobody can sign in; SDK and site keys created meanwhile are stored hash-only, so Settings shows only their mask. | Every session cookie (`dectog_session`, HS256, 30-day JWT) and every in-flight Google sign-in becomes invalid: everyone signs in again. The encrypted copies of SDK and site keys (`api_keys.key_enc`, AES-256-GCM under SHA-256 of `dectog-api-key-v1:` + the secret) can no longer be decrypted, so Settings shows those keys masked, and webhook deliveries fail with “The signing secret cannot be read (JWT_SECRET changed): rotate the webhook secret” until each secret is rotated. API authentication keeps working unchanged because it compares `sha256(key)`, which the secret never touches; the account key has no stored copy at all. |
| `IP_SALT` | Yes in production; optional locally (`dtg-default-salt` with a warning) | In production every request that hashes an IP fails with `IP_SALT is not set`: link taps, installs, claims, app opens, the beacon and the per-IP rate limit. | Every new `ip_hash` differs from every stored one. Fingerprint matching cannot see clicks made before the rotation, the `install_velocity_ip` and `click_storm_ip` rules lose their history, beacons for clicks made before the rotation are rejected (the beacon must match the click’s hash), and Overview unique visitors double-count across the boundary. Rotation does not re-identify stored hashes. |
| `BOOTSTRAP_TOKEN` | Only for the bootstrap call in production | Locally: bootstrap needs no header. In production: every bootstrap call is `403`. | Harmless. Delete it after the owner account exists. |
| `RESEND_API_KEY` | No | Password-reset links are printed to the Worker console (`wrangler tail`) instead of being emailed; the user still sees the generic “If that email exists, a reset link is on its way.” message. | Harmless; nothing is stored. |
| `CF_API_TOKEN`, `CF_ZONE_ID` | No, but both, with `CUSTOM_DOMAIN_TARGET` | Custom link domains are off: the Settings card says they are not set up and the admin routes answer `409 not_configured`. Domains already active keep being served. | A new token with the same permission (SSL and Certificates: Edit on the zone) is harmless. Another zone loses every custom hostname the old one had. See [Custom domains](/self-hosting/custom-domains/). |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | No, but both or neither | `GET /api/auth/config` reports `googleEnabled: false`, the login page hides the button and `/api/auth/google/start` answers `404` (`not_configured`). | Rotating the secret within the same Google Cloud project is harmless. A client in a different project issues different `sub` values, so stored `users.google_sub` links no longer match: Google sign-in then finds no account (an email match is never linked) and each person signs in with a password (an account made with Google sets one with **Forgot password?**) and connects Google again from Settings → Account. |
Session tokens are stateless: rotating `JWT_SECRET` is the only way to revoke every session at once; there is no per-user revocation. Password-reset tokens are different: they are stored hashed in `password_resets` and do not depend on the secret.
## Per-app options
[Section titled “Per-app options”](#per-app-options)
Settings that apply per registered app live on the `apps` row and are edited under Apps in the dashboard or through the admin API. Validation is in `apps/worker/src/appvalidation.ts`; the full form is on [Apps](/dashboard/apps/).
| Option | Column | Rule |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Name | `name` | Required when creating |
| Fallback URL | `fallback_url` | http(s) URL. On mobile it outranks the link target; on desktop it is the last stop before the QR page. |
| iOS bundle id, Team ID, App Store URL, scheme | `ios_bundle_id`, `ios_team_id`, `ios_store_url`, `ios_app_scheme` | Bundle id `^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$`; Team ID `^[A-Z0-9]{10}$`; store host exactly `apps.apple.com`; scheme letters, digits, `+ - .`, without `://`, never http, https, ws, wss, javascript, file, data, blob, about or intent |
| Android package, SHA-256 fingerprints, Play URL, scheme | `android_package`, `android_sha256_fingerprints`, `android_store_url`, `android_app_scheme` | Package as the bundle id; fingerprints `AA:BB:…` (32 pairs, stored upper-case, JSON array); store host exactly `play.google.com`; scheme as iOS |
| OEM store URLs | `huawei_store_url`, `samsung_store_url`, `xiaomi_store_url`, `vivo_store_url` | Host-checked for Huawei (`appgallery.huawei.com`, `appgallery.cloud.huawei.com`, `url.cloud.huawei.com`), Samsung (`galaxystore.samsung.com`, `apps.samsung.com`, `galaxy.store`) and Xiaomi (`global.app.mi.com`, `app.mi.com`), subdomains included; vivo accepts any http(s) URL. See [App stores and OEM routing](/links/app-stores-and-oem-routing/). |
| Facebook App ID | `fb_app_id` | Free text; when set, the Android SDK reads Meta’s on-device install referrer |
| Social preview | `og_title`, `og_description`, `og_image_url` | 150 characters, 150 characters, http(s) URL |
| Mobile interstitial | `mobile_interstitial` | 0/1. Serves the beacon page on mobile taps so the click row gets a screen profile and device settings. |
| iOS tap-to-store | `ios_tap_to_store` | 0/1. One-tap page that copies the link to the pasteboard before opening the App Store. |
| Strict fraud mode | `strict_fraud_mode` | 0/1. Flagged installs count as organic. |
| Low-entropy window | `low_entropy_window_hours` | Whole number 1 to 720, or empty for `ATTRIBUTION_LOW_ENTROPY_WINDOW_HOURS` |
| Pasted-link and Meta window | `deterministic_window_hours` | Whole number 1 to 720, or empty for `ATTRIBUTION_DETERMINISTIC_WINDOW_HOURS` |
| Re-engagement window | `reengagement_window_hours` | Whole number 1 to 720, or empty for `REENGAGEMENT_WINDOW_HOURS` |
`strict_fraud_mode`, `low_entropy_window_hours`, `deterministic_window_hours` and an install’s webhook app are read from the app an install concerns (`apps/worker/src/appscope.ts`): the app of the credited or routed link; else the install’s own app, the one whose Android package or iOS bundle ID equals the `package_name` the SDK sends (on an account with one app, that app); else the oldest app. The redirect engine uses the link’s own app for routing, store URLs, schemes, interstitial and tap-to-store.
## Fixed constants
[Section titled “Fixed constants”](#fixed-constants)
These are code constants. Changing them means editing the source and redeploying.
| Constant | Value | Where |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- |
| Rate limits | 3,000 requests per minute per account; 60 per device; 600 per account + IP when no device id; beacon 120 per minute per IP; contact form 5 per hour per IP | `ratelimit.ts`, `collect.ts`, `api_blog.ts` |
| Request signature skew | ±1,800 s | `auth.ts` |
| Session lifetime | 30 days (JWT); cookie Max-Age 30 days with “Remember me”, otherwise a browser-session cookie | `auth.ts`, `api_admin.ts` |
| Password reset | Token valid 1 hour, single use | `api_auth.ts` |
| Google username step | 15 minutes | `api_google.ts` |
| Temp links | TTL 60 s to 2,592,000 s (30 days), default 900; deleted (softly) 24 h after expiry | `api_links.ts`, `index.ts` |
| Click screen profile and device settings | Nulled after `min(RETENTION_DAYS, 30)` days | `index.ts` |
| Beacon binding | Same hashed IP, within 10 minutes of the click | `collect.ts` |
| Bot threshold | Cloudflare bot score below 30 records no click | `redirect.ts` |
| Fingerprint candidate pool | Newest 50 web clicks from the same hashed IP | `attribution.ts` |
| Fraud thresholds | Velocity 5 (Wi-Fi/unknown) or 25 (cellular) installs per hashed IP in 24 h; click storm over 100 clicks per hashed IP in the hour before the click; impossible speed under 10 s; stale referrer over 30 days | `fraud.ts` |
| Apple Search Ads | 3 inline attempts, 24 h token TTL, 100 tokens per cron pass | `asa.ts` |
| Reserved usernames | `app www api links dashboard admin mail static cdn` | `bootstrap.ts` |
| Brand name | `DecTog` | `brand.ts` |
# What it costs to run
> Measured D1 rows and storage per tap, install, event and ping, what fits in the Free plan's day, the monthly Cloudflare bill at 100k to 10M taps, how many requests a deployment answers at once, and how the retention window sets the storage it needs.
A self-hosted DecTog runs on one Cloudflare account: one Worker, one D1 database. This page turns measured usage into a monthly bill, and states where a single deployment stops scaling today.
## What one operation uses
[Section titled “What one operation uses”](#what-one-operation-uses)
Measured by the worker’s test suite (`apps/worker/test/cost.test.ts`), which calls each route with a metered database and fails the build when an operation gets more expensive. D1 bills rows, and every index update counts as one more written row.
| Operation | Requests | D1 rows read | D1 rows written | Storage |
| -------------------------------------------------------------- | ----------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| A tap on a link (redirect) | 1 | 5 | 5 | about 670 bytes |
| First-launch install registration | 1 | 14, plus up to 50 when the fingerprint scans candidates | 7 | about 960 bytes |
| Event batch (one conversion) | 1 | 4 | 6, or 7 when it names the install | about 570 bytes |
| Screen views (one events flush, however many screens it holds) | 1 | 3 | 4 for an app session’s first flush (its row and indexes), 1 for each later flush of the session | about 60 bytes per screen view, in its session’s row; a session keeps 50 |
| App start ping (the SDKs send one a day per device) | 1 | 2 | 2 for the device’s first ping of the UTC day, 0 after | about 150 bytes per device and day |
| Link opened in the installed app (click plus open) | 2 | 7 | 10 | about 1 KB |
| Creating a link | 1 | 4 | 4 | small |
| The daily rollup, per day | none (cron) | about 2 per raw row of that day | one per link and platform active that day | about 100 bytes per written row |
The SDK rows are measured with an SDK key. Its lookup reads the key and its account in one statement, counted above, and an isolate keeps the pair for `SDK_AUTH_CACHE_SECONDS` (60 by default), so most calls read nothing for it. Creating a link includes its [audit log](/dashboard/settings/#audit-log) row: one row read for the entry’s number and one written. Every change to an account’s configuration (a link, an app, a webhook, a key, a sign-in) pays the same; taps, installs, events, pings and the other SDK traffic write no audit rows.
The rate limits on authenticated `/api/v1` calls and on the interstitial beacon are counted by Cloudflare’s [Rate Limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) bindings, which `wrangler.jsonc` and the setup script declare (`RL_TENANT`, `RL_DEVICE`, `RL_IP`, `RL_BEACON`). A deployment without them falls back to counters in D1, which add 2 read and 4 written rows to every v1 call. The sign-in and password-reset limits stay in D1.
## A month of traffic
[Section titled “A month of traffic”](#a-month-of-traffic)
Assumptions, per month: one install for every five taps; each install starts the app 8 times, on 8 days, and sends 3 event batches without screen views; one tap in ten opens an installed app. Prices are Cloudflare’s [Workers Paid](https://developers.cloudflare.com/workers/platform/pricing/) and [D1](https://developers.cloudflare.com/d1/platform/pricing/) prices as of October 2026: $5 a month including 10 million requests, 30 million CPU milliseconds, 25 billion rows read, 50 million rows written and 5 GB of storage; then $0.30 per million requests, $0.02 per million CPU ms, $0.001 per million rows read, $1.00 per million rows written and $0.75 per GB-month. CPU time is taken as 5 ms per request: measured in production, taps, pings, events and dashboard calls use 2–5 ms at the median and an install about 10 ms.
| Taps a month | Requests | Rows read | Rows written | Storage added | Monthly bill |
| ------------ | -------- | --------- | ------------ | ------------- | ----------------------------------------------- |
| 100,000 | 0.4 M | 2.7 M | 1.5 M | 0.15 GB | $5 |
| 1,000,000 | 3.6 M | 27 M | 15 M | 1.5 GB | $5, plus storage beyond 5 GB |
| 3,000,000 | 10.8 M | 81 M | 45 M | 4.6 GB | about $5, plus storage beyond 5 GB |
| 10,000,000 | 36 M | 270 M | 150 M | 15 GB | about $116, plus storage; see the ceiling below |
Rows read include the fingerprint’s candidate scan at its maximum and the daily rollup; they never come near the 25 billion included. Screen tracking adds, per app session, 4 written rows for its first flush and 1 for each later one (one flush every 30 seconds while screens change), on top of these.
The Workers Free plan caps each day at 100,000 requests, 5 million rows read and 100,000 rows written for the whole account; past any of them the deployment stops answering until midnight UTC. What fits in one day, if a single kind of traffic had the quota to itself:
* **Taps:** about 20,000 (5 written rows each).
* **App sessions:** about 9,000 three-minute sessions that track a screen every 30 seconds (about 11 written rows each: the day’s ping, the session’s row and five more flushes), which is about 19 people in the app at any moment on average. Without screen tracking, the day’s ping and one event, about 11,000.
* **Dashboard:** the Overview re-reads the last 7 days of raw clicks and installs every 15 seconds while it is open, about 22 rows per click. At 1,000 clicks a week, one open tab uses the day’s 5 million reads in about an hour.
So the Free plan suits trying DecTog out, and the Paid plan suits any real traffic.
## The ceiling: storage and the retention window
[Section titled “The ceiling: storage and the retention window”](#the-ceiling-storage-and-the-retention-window)
A D1 database holds at most 10 GB (500 MB on the Workers Free plan). The daily job deletes clicks, installs, events and app opens older than `RETENTION_DAYS` (default 400) once the daily rollups hold their day, so storage levels off at about the retention window’s worth of raw rows. The rollups keep the totals beyond it: the Overview’s clicks, installs, opens, leads and revenue, and every link’s lifetime numbers. Breakdowns by country, referrer, campaign, device model and the like read raw rows, so they cover the retention window only. So does the Overview’s Breakdown card and its analytics query, and each answer reads the window’s raw rows of every table it counts: for a 90-day window at 100 taps a day, about 9,000 click rows plus the installs, opens and events, each with its click. Its LTV column also reads every later event of the window’s installs, through an index on the install.
| Taps a month | Raw rows for 400 days | Longest `RETENTION_DAYS` that fits 10 GB |
| ------------ | --------------------- | ---------------------------------------- |
| 100,000 | about 2.3 GB | the default, 400 |
| 1,000,000 | about 23 GB | about 175 |
| 3,000,000 | about 69 GB | about 55 |
| 10,000,000 | about 230 GB | about 17 |
The rollups themselves are small: one row of about 100 bytes per link, platform and day with activity, about 11 MB a year for 100 busy links. One deployment therefore suits a few million taps a month with a shorter retention window; see [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/).
## The ceiling: requests at the same moment
[Section titled “The ceiling: requests at the same moment”](#the-ceiling-requests-at-the-same-moment)
Every request reads or writes the one D1 database, and nothing caches in front of it. A load test against a production deployment in October 2026 found that the database answers about 400 to 450 queries a second. That figure holds whatever the queries are: each statement’s SQL took about 0.2 ms, so the limit is the cost of each round trip, not the SQL. Taps, SDK calls and dashboard loads all share it.
| Traffic | D1 round trips per request | Holds comfortably | Past the ceiling |
| ------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Taps on a link | 4 | 50 a second (3,000 a minute) | from about 100 a second, answers take 2 to 3 seconds; every tap is still recorded at 150 a second |
| SDK calls (pings, events) | 1 to 3 (3 to 5 when measured, before the key lookup became one read, kept per isolate) | 100 a second measured; about 250 expected now, not yet measured | at 200 a second, with the lookups of the measurement, 1 call in 6 failed with a 500 |
| Dashboard Overview loads | 36 | 5 a second (about 75 open tabs) | at 20 a second, 3 calls in 10 fail |
An app in use sends at most 2 SDK calls a minute (the SDKs flush events every 30 seconds), so 100 calls a second was about 1,500 to 1,800 people using apps at once, across every app on the deployment; with a flush at 3 round trips instead of 5, roughly 2,500 is expected. The per-account limit of 3,000 calls a minute is approximate: Cloudflare’s Rate Limiting counters let one account through at 4,500 a minute in the test. An install makes about 15 sequential round trips, so it takes about 400 ms even when the database is idle.
## Compared with a measurement partner
[Section titled “Compared with a measurement partner”](#compared-with-a-measurement-partner)
Mobile measurement partners price per attributed install or conversion: AppsFlyer’s published rate was $0.07 per conversion in 2026. At 200,000 installs a month that is about $14,000; the same traffic, a million taps, costs $5 here, with `RETENTION_DAYS` at about 175.
# Custom domains
> Turn on customers' custom link domains on a self-hosted deployment with Cloudflare for SaaS, and how the Worker validates, serves, re-checks and deletes them.
Custom link domains (`go.yourbrand.com`, one per account) use [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/) custom hostnames on the deployment’s zone. The feature is off until the three settings below are present; the Settings card then says it is not set up and the admin routes answer `409 not_configured`. What account holders see is in [Links: custom domains](/links/custom-domains/).
## Turn it on
[Section titled “Turn it on”](#turn-it-on)
1. In the Cloudflare dashboard, open the zone (`dectog.com` below) and enable **SSL/TLS > Custom Hostnames** (Cloudflare for SaaS).
2. Add a proxied DNS record for the target customers point at, for example `domains.dectog.com` as `AAAA 100::`, and set it as the **fallback origin** on the same page.
3. Create an API token with the **SSL and Certificates: Edit** permission on the zone.
4. Set the secrets and the variable:
```bash
npx wrangler secret put CF_API_TOKEN --env production
npx wrangler secret put CF_ZONE_ID --env production
```
and `"CUSTOM_DOMAIN_TARGET": "domains.dectog.com"` under `env.production.vars` in `apps/worker/wrangler.jsonc`.
5. Route the customers’ hostnames to the Worker: uncomment the `*/*` route next to `dectog.com/*` and `*.dectog.com/*` in `wrangler.jsonc`. The existing routes already cover every host on the zone, so the new one adds only custom hostnames.
6. Deploy. The `custom_domains` table is already part of the schema, so there is no migration to apply.
On a deployment made with `npm run setup`, steps 4 and 5 are one option: put the two secrets with `--config wrangler.self-host.jsonc` in place of `--env production`, and pass `--custom-domain-target domains.example.com` to setup, which adds the variable and the `*/*` route (setup rewrites its config on every run, so pass it every time). The [Terraform module](/self-hosting/deploy/#infrastructure-as-code-with-terraform) with `custom_domains_origin = true` does step 2.
Cloudflare includes a number of custom hostnames with each plan and charges per hostname beyond it; see Cloudflare for SaaS pricing.
## How the Worker handles a domain
[Section titled “How the Worker handles a domain”](#how-the-worker-handles-a-domain)
| Step | What happens |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Add | `POST /zones//custom_hostnames` with `ssl: {method: "txt", type: "dv"}`. The answer’s `ownership_verification` and `ssl.validation_records` become the TXT records the card shows, plus the CNAME to `CUSTOM_DOMAIN_TARGET`. A hostname another Cloudflare zone already has is refused (`409 hostname_taken`). |
| Check | `GET` the custom hostname. Its `status` and `ssl.status` map to the six states: `pending_validation`, `issuing_certificate`, `ready` (both active), `active`, `moved`, `failed` (blocked, deleted, or a certificate step timed out). |
| Ready to active | A DNS-over-HTTPS CNAME lookup (`cloudflare-dns.com`) that finds `CUSTOM_DOMAIN_TARGET`; or a request that reaches the Worker on the host while public DNS sends it to Cloudflare (the CNAME, or A/AAAA records all inside Cloudflare’s ranges from `/client/v4/ips`, for proxied records), looked up at most once a minute per host. A request alone is not enough: a pre-validated hostname is on Cloudflare’s edge before the customer moves DNS, so anyone can reach it. Then `users.custom_domain` is set, and every link URL is built on it. |
| Re-check | An active domain trusts Cloudflare’s own status: `moved` means it no longer points at the fallback origin. The domain then stops being canonical, and is deleted after 7 days unless it comes back. |
| Remove | Suspends: `users.custom_domain` is cleared at once, the host is still served, and the hostname is deleted 7 days later. A domain that never served is deleted at once. |
The hourly cron (`sweepCustomDomains`) checks pending domains not checked for ten minutes, re-checks active ones once a day and deletes the ones past their grace, 50 of each per run. The dashboard card checks every minute while it is open on a pending domain.
A request on any host that is not the root domain, one of its subdomains, `APP_HOST`, the `PUBLIC_BASE_URL` or `WEB_URL` host, `localhost`, an IP or `*.workers.dev` is looked up in `custom_domains` (states `ready`, `active` and `moved`, and suspended ones within their grace). The answer is cached per isolate for a minute, misses included, so a tap on the default hosts costs no extra read and a tap on a custom host at most one a minute.
# Database and migrations
> The 35 D1 tables, the schema file, the append-only rule, and the typed data layer that keeps schema, row types and SQL in step.
DecTog stores everything in one Cloudflare D1 (SQLite) database, bound as `DB`. The Worker talks to it with raw, parameterized SQL; there is no ORM. The rules are summarized below.
## Tables
[Section titled “Tables”](#tables)
The schema has 35 tables (`apps/worker/src/db/tables.ts`). Every table is scoped by the account (`user_id`, or `tenant_id` in `memberships` and `invites`) except the platform tables: `rate_limits`, the blog and contact tables, `fx_rates` and the device model tables.
| Table | One line |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `users` | Accounts and sign-ins: email, PBKDF2 password hash, username (the link subdomain), `api_key_hash` + `key_prefix` (the account key is never stored readable; the three are all `NULL` for someone who joined from an invite and has no account of their own, and a CHECK keeps them together), `role` (`owner` or `user`), `google_sub`, `retention_days` (the account’s own window, or `NULL`), `test_of` (on a test environment: its live account), `session_epoch` (raised to end every session) |
| `apps` | Registered apps: store identifiers and URLs, schemes, fallback URL, social preview, interstitial, tap-to-store, strict fraud mode, Facebook App ID, OEM store URLs, low-entropy window |
| `links` | Permanent short links: slug (unique per tenant together with `temp_links`), optional target, metadata JSON, `click_count`, social preview, deep-link paths, redirect and fallback URLs, tracking defaults (`label`, `campaign`, `adgroup`, `creative`) |
| `temp_links` | Temporary links: slug, target, `ttl_seconds`, `expires_at`, social preview; no counters |
| `clicks` | One row per recorded tap or in-app open: link, device and browser, geo and ASN, hashed and raw IP (no raw IP with `STORE_RAW_IP=0`), UTM, `link_params`, beacon screen profile and device settings, client hints, `origin` (`redirect`, `sdk`, `synthesized_referrer`) |
| `app_installs` | One row per install registration: device tier, referrer telemetry, the attribution verdict, fraud flags, shadow match, Apple Search Ads lifecycle, idempotency key |
| `ad_identifiers` | Advertising id per install, isolated so it is never joined to the device tier |
| `customers` | The tenant’s own user identities from `identify` events: `customer_id`, name, email, phone, `first_install_id` |
| `events` | `identify`, `lead`, `conversion` and `custom` events with revenue, `matched_slug` denormalized at insert, metadata JSON, raw IP (none with `STORE_RAW_IP=0`) |
| `app_sessions` | One row per app session with screen views, keyed by the SDK’s `session_id` and written once per flush: the device, install, customer and link open the session is first known by, the device time of its first screen, the screen count and the first 50 screens as JSON. The Screens lists of customers and installs and the links page’s Screens after open read it |
| `device_days` | One row per device and UTC day the app started (device, app version, OS, the first ping’s time): with app opens and events, the activity behind the Overview’s Retention card |
| `app_opens` | Warm opens: a link opened an already-installed app (slug, click id, device, hashed IP) |
| `rate_limits` | Fixed-window counters keyed by account id, `dev::`, `ip::` or `webcollect:` |
| `blog_categories` | Marketing-site blog categories (platform-wide, owner-managed) |
| `blog_posts` | Blog posts: Tiptap HTML plus optional block JSON, published flag and dates |
| `contact_messages` | Submissions from the public contact form |
| `password_resets` | Single-use reset tokens, stored as SHA-256 hashes with expiry and `used_at` |
| `api_keys` | SDK keys (`dtg_pub_…`) and site keys (`dtg_site_…`, with their allowed `origins`): name, kind, scopes, SHA-256 of the key, prefix, the encrypted viewable key, `last_used_at`, `revoked_at` |
| `link_templates` | Named defaults a link can take (`links.template_id`), as JSON |
| `referrals` | One row per referred install: the referring and the referred customer, the link, the match source |
| `rejected_requests` | SDK calls refused with 400 or 422, for the debugging view: route, reason, the body’s hash and size, never the body; 30 days |
| `app_store_endpoints` | Per app, the secret token in its Apple and Google store-notification URLs, and the Pub/Sub service account Google signs with |
| `store_notifications` | The store notifications received, with their outcome; 90 days |
| `fx_rates` | The ECB’s daily euro reference rates, for revenue in the account’s currency |
| `link_daily` | The daily rollups: per account, link, UTC day and platform, the clicks, installs, opens, leads and net revenue |
| `rollup_state` | Per account, the last day the rollups hold (`through_day`) and the last day whose raw rows are gone (`frozen_through`) |
| `webhooks` | Webhook endpoints: URL, optional app, events, encrypted signing secret, failure count and why one turned off |
| `webhook_deliveries` | One row per event and webhook: the envelope’s data, attempts, the last response; 30 days |
| `export_jobs` | CSV exports past `EXPORT_STREAM_MAX_ROWS` rows: the query, progress, the R2 key and when the file goes |
| `device_models`, `device_model_refresh` | Marketing names (brand, name) for the Android models installs reported, keyed by the lowercased model, and when Google’s list was last read |
| `memberships` | A person’s role (`owner`, `member`, `viewer`) in another person’s account; keyed by account and person |
| `invites` | Team invitations: the account, the invited email and role, the SHA-256 of the token, expiry and when accepted |
| `erasures` | One row per erased device or customer for 8 days: the SHA-256 of the account, the kind and the id, never the id itself, with when it was erased; events queued earlier are dropped on arrival |
| `audit_events` | The audit log: per account, entries numbered by `seq` (the key, with `user_id`), the action, who acted, the target, the changed fields with secrets redacted, hashed IP, user agent, request id. Only ever inserted; kept as long as the account |
| `custom_domains` | Each account’s custom link domain: the Cloudflare hostname id, the state, the DNS records to add, errors, the checks, the suspension and its end. The active one is also `users.custom_domain`. |
There are no foreign-key constraints. Cascades (deleting a link, deleting an app, unlinking a category) are done in the API layer.
## Migrations
[Section titled “Migrations”](#migrations)
`apps/worker/migrations/` holds `0001_schema.sql`, which creates the 36 tables above and their 54 indexes, plus any migrations after it. `wrangler d1 migrations apply` runs the files in the folder that are not yet recorded in the database’s `d1_migrations` table, in filename order, and the test setup (`test/setup.ts`) applies them the same way with `localeCompare`. A schema change is the next numbered file.
The 63 migrations written before the first release were combined into `0001_schema.sql` on 2026-10-16, and the two after them on 2026-10-20. A local database made before then has the old files recorded, so `migrations apply` stops at the first table it creates (`table users already exists`): delete `apps/worker/.wrangler/state/v3/d1` and apply again.
### Commands
[Section titled “Commands”](#commands)
```bash
cd apps/worker
npm run db:migrate # local: wrangler d1 migrations apply DB --local
npx wrangler d1 migrations list DB --remote --env production # what is pending
npx wrangler d1 migrations apply DB --remote --env production # production
npx wrangler d1 execute dectog --local --command "SELECT name FROM sqlite_master WHERE type='table'"
npx wrangler d1 execute dectog --remote --env production --command "SELECT COUNT(*) FROM users"
```
`DB` is the binding name and `dectog` the database name; `migrations` accepts the binding, `execute` is shown with the name. The local database lives under `apps/worker/.wrangler/state/v3/d1`; delete that directory to start over. `npm run db:migrate:remote` runs the production command above.
### The append-only rule
[Section titled “The append-only rule”](#the-append-only-rule)
Once a migration has been applied to a shared or remote database, its file is never edited, renamed or deleted. A change to the schema is a new file with the next number, and its file name goes into `apps/worker/src/migrations.ts`, the list [`/readyz`](/api/overview/#health-check) compares with `d1_migrations`; `test/readyz.test.ts` fails until the list and the folder agree. Rolling back a Worker does not roll back a migration, and nothing in the repository reverts one; see [Deploy](/self-hosting/deploy/).
## The typed data layer
[Section titled “The typed data layer”](#the-typed-data-layer)
Three artifacts describe every table, and two checks keep them identical:
1. **Row types** in `apps/worker/src/types.ts` (`UserRow`, `AppRow`, `LinkRow`, `ClickRow`, `InstallRow`, and so on). Every read carries one: `.first()`, `.all()`.
2. **The column registry** in `apps/worker/src/db/tables.ts`: `TABLES. = table({ column: true, … })`. Its type is `Record`, so a key missing from or unknown to the row type is a compile error (`npm run typecheck`).
3. **The migrations**, compared with the registry by `test/schema.test.ts`: for every registry entry `PRAGMA table_info()` must list exactly the registered columns, and every table in `sqlite_master` (ignoring `sqlite_*`, `_cf_*` and `d1_migrations`) must have a registry entry.
Writes go through `apps/worker/src/db/write.ts`:
* `insertStmt(db, table, row)` takes the full row type and derives the column list from the object’s keys, checked against the registry. Adding a column to a row type turns every insert site into a compile error until it supplies a value; an `undefined` value throws at runtime (`bind null instead`). It returns a prepared statement, so it works inside `db.batch()`.
* `updateById(db, table, id, patch)` takes a typed partial patch for tables keyed by `id` and skips `undefined` keys.
* Expression updates (`click_count = click_count + 1`), the analytics, attribution, rate-limit and cron SQL stay as raw statements with `?` placeholders. Request input never reaches SQL text.
* The link, temp-link and user lookups live once in `src/db/links.ts`, `tempLinks.ts` and `users.ts`.
* Time cutoffs are computed in JavaScript (`isoDaysAgo` in `util.ts`) and bound as ISO strings. Stored timestamps are JavaScript ISO strings (`…T…Z`); SQLite’s `datetime('now', …)` emits `YYYY-MM-DD HH:MM:SS`, and because `T` sorts after a space the two never compare correctly.
### Adding a column
[Section titled “Adding a column”](#adding-a-column)
1. Create `apps/worker/migrations/0028_.sql` with the `ALTER TABLE … ADD COLUMN` (nullable, or with a default).
2. Add the field to the row type in `types.ts` and the key to the registry in `tables.ts`.
3. Fix every insert site the compiler now flags (`npm run typecheck`).
4. `npm run db:migrate` locally, then `npm test`: `schema.test.ts` fails until the three agree.
5. Deploy with the release routine (migrations first, then the Worker).
# Deploy to Cloudflare
> Put DecTog on your own zone, run the first deploy and the release routine, verify it and roll back when needed.
Production is the `production` environment in `apps/worker/wrangler.jsonc`: one Worker bound to two routes on your zone, one D1 database, two cron triggers and a static-assets binding that serves the dashboard. The same Worker answers the API, the admin API, the link redirects, the well-known files and the dashboard host. No domain yet? [One click on workers.dev](#one-click-on-workersdev) deploys the same Worker on your workers.dev subdomain.
## One click on workers.dev
[Section titled “One click on workers.dev”](#one-click-on-workersdev)
[](https://deploy.workers.cloudflare.com/?url=https://github.com/DecTog/dectog)
The button runs DecTog on your Cloudflare account without a zone: Cloudflare clones the server repository into your GitHub account, creates a D1 database, builds the dashboard, deploys the Worker to `https://..workers.dev` and connects the copy to Workers Builds, which redeploys it on every push to `main`. The deployment is the `wrangler.jsonc` at the root of the repository (the hosted service and `npm run setup` use `apps/worker/wrangler.jsonc`), and the root `package.json` holds its build and deploy commands: the build installs both apps and exports the dashboard, the deploy applies the pending migrations and then deploys.
1. Click the button and sign in to Cloudflare. Choose the repository name, the Worker name (`dectog` unless you change it) and the database name.
2. Fill in the secrets: `JWT_SECRET` and `IP_SALT`, each from `openssl rand -hex 32`. `IP_SALT` salts every stored IP hash, so never change it afterwards; the Worker refuses to run without it.
3. Fill in the variables with the Worker’s own URL. Your workers.dev subdomain is on **Workers & Pages > Overview**; with the Worker named `dectog` the URL is `https://dectog..workers.dev`.
| Variable | Value |
| ---------------------------------------- | ---------------------------------------- |
| `PUBLIC_BASE_URL`, `WEB_URL`, `SITE_URL` | `https://dectog..workers.dev` |
| `APP_HOST`, `ROOT_DOMAIN` | `dectog..workers.dev` |
A wrong value is corrected later under the Worker’s **Settings > Variables and Secrets**; the Worker reads them on every request.
4. Deploy. The first build takes a few minutes (two `npm ci` runs and the Next.js export). `https://dectog..workers.dev/readyz` then answers `200` with no missing migrations.
5. Create your account at `https://dectog..workers.dev/login?tab=signup`: the bootstrap route is closed (`ENVIRONMENT=production`, no `BOOTSTRAP_TOKEN`) and signups are open. Then add `OPEN_SIGNUPS` = `0` to the Worker’s variables so nobody else can sign up, and continue with [Apps](/dashboard/apps/).
What is different from a deployment on your own domain:
* Links are path mode, `https://dectog..workers.dev/r//`, because tenant subdomains need a zone with a wildcard record. The SDK is configured with `baseUrl` and no `linkRootDomain`; the AASA and Asset Links files are served from that same host, so Universal Links and App Links work on it.
* The one host serves the public site, the dashboard and the API; the export’s links between them are relative.
* There is no `BOOTSTRAP_TOKEN` and no demo seed.
* The R2 exports bucket and customers’ custom link domains are not bound. Add them to your copy’s `wrangler.jsonc` as `apps/worker/wrangler.jsonc` describes.
To move to your own domain later, edit the root `wrangler.jsonc` of your copy: add the two `routes` from the production environment of `apps/worker/wrangler.jsonc` with your zone, set `LINK_URL_MODE` to `subdomain`, `ROOT_DOMAIN` to the apex, `APP_HOST` to `app.` and the three URLs accordingly, add the proxied `A` records for the apex and `*`, and push. Workers Builds redeploys; the database, the secrets and your account stay.
## Cloudflare prerequisites
[Section titled “Cloudflare prerequisites”](#cloudflare-prerequisites)
* A zone on Cloudflare (the reference configuration uses `dectog.com`). Replace it with your own domain everywhere below.
* DNS, both proxied (orange cloud): a record for the apex, and a wildcard record `*` pointing at the apex. Worker routes only see traffic that passes through Cloudflare’s proxy, so an unproxied record never reaches the Worker. Tenant links live on `.`, the public site on the apex (`SITE_URL`) and the dashboard on `APP_HOST` (`app.`), and the two records cover all three.
* Two routes with `zone_name` set: `/*` and `*./*`. A more specific route in the same zone (for example a separate docs site on `docs./*`) takes precedence over the wildcard without any change here.
* Usernames `app`, `www`, `api`, `links`, `dashboard`, `admin`, `mail`, `static`, `cdn`, `docs`, `status`, `help` and `support`, and every name starting with `t-` (the prefix of test environments), are reserved (`apps/worker/src/bootstrap.ts`), so no signup can claim those hosts. Any other subdomain you use for something else can be claimed by a signup unless you add it to that list or close signups with `OPEN_SIGNUPS="0"`.
* Node 22.12 or newer and a `wrangler login` session on the account that owns the zone (`npx wrangler whoami`).
## The production environment
[Section titled “The production environment”](#the-production-environment)
Edit `env.production` in `apps/worker/wrangler.jsonc`. The file as shipped carries the hosted service’s domain and D1 id; replace every value marked here.
apps/worker/wrangler.jsonc (env.production)
```jsonc
"production": {
"routes": [
{ "pattern": "example.com/*", "zone_name": "example.com" },
{ "pattern": "*.example.com/*", "zone_name": "example.com" }
],
"vars": {
"ENVIRONMENT": "production",
"ROOT_DOMAIN": "example.com",
"LINK_URL_MODE": "subdomain",
"PUBLIC_BASE_URL": "https://app.example.com",
"WEB_URL": "https://app.example.com",
"SITE_URL": "https://example.com",
"APP_HOST": "app.example.com",
"UNKNOWN_HOST_REDIRECT": "https://example.com/",
"RETENTION_DAYS": "400",
"ATTRIBUTION_WINDOW_HOURS": "24"
},
"triggers": { "crons": ["17 3 * * *", "7 * * * *"] },
"d1_databases": [
{ "binding": "DB", "database_name": "dectog", "database_id": "", "migrations_dir": "migrations" }
],
"assets": { "directory": "../web/out", "binding": "ASSETS", "run_worker_first": true, "html_handling": "auto-trailing-slash" }
}
```
`run_worker_first` matters: the Worker decides every request before the asset server does, so `./login` is a link slug, not the dashboard’s login page. Every variable is described on the [Configuration reference](/self-hosting/configuration-reference/). Add `"OPEN_SIGNUPS": "0"` to `vars` if only you should have an account, and `"EMAIL_FROM"` with a sender you verified in Resend if you set `RESEND_API_KEY`.
## One-command setup
[Section titled “One-command setup”](#one-command-setup)
From `apps/worker`, logged in with `npx wrangler login`:
```bash
npm run setup -- --domain example.com --email you@example.com --username acme
```
It checks the login, finds or creates the D1 database, writes `wrangler.self-host.jsonc` for your domain (your deployment’s config, kept out of git), applies every migration, builds the dashboard, deploys the Worker, sets `JWT_SECRET`, `IP_SALT` and `BOOTSTRAP_TOKEN` only if they are missing, creates your owner account, prints the dashboard URL, the SDK key and the account key once, and deletes `BOOTSTRAP_TOKEN`. Add `--dry-run` to print every step without running any.
It is safe to run again: a finished step is skipped, and existing secrets are never rotated, because a new `IP_SALT` would break every stored hash and a new `JWT_SECRET` every stored key copy and session. DNS is the one manual step: in the Cloudflare dashboard for the zone, add an `A` record for the apex and one for `*`, both proxied (or let the [Terraform module](#infrastructure-as-code-with-terraform) create them). When `https://app./readyz` does not answer yet, the script says which records to add and stops; run it again afterwards.
Two options add to the config. Pass them on every run, because the config is rewritten each time: `--exports-bucket ` binds the R2 bucket for CSV exports too large to stream (with `--exports-jurisdiction eu`, `fedramp` or `us` when the bucket is kept in one), and `--custom-domain-target ` turns on customers’ custom link domains ([Custom domains](/self-hosting/custom-domains/)).
For later releases, use `--config wrangler.self-host.jsonc` instead of `--env production` in the commands below. What it costs to run is on [What it costs to run](/self-hosting/cost/).
## Infrastructure as code with Terraform
[Section titled “Infrastructure as code with Terraform”](#infrastructure-as-code-with-terraform)
`deploy/terraform` in the server repository is a Terraform module for the resources the Worker runs on: the zone (optional), the proxied apex and wildcard DNS records, the D1 database (kept in a jurisdiction, `eu`, `fedramp` or `us`, when you set one), and optionally the exports bucket and the custom-domain fallback origin. Its `next_step` output is the `npm run setup` command for what it created, with the flags that bind it; setup finds the database by name and does the rest.
Secrets stay out of it on purpose. Terraform keeps every value it manages in its state in plain text, so `JWT_SECRET`, `IP_SALT` and `BOOTSTRAP_TOKEN` are generated by setup and handed to the Worker without being written anywhere. The module’s README lists the API token permissions and every variable, and `terraform test` checks the plan against a mocked provider, with no account needed.
## First-time setup (by hand)
[Section titled “First-time setup (by hand)”](#first-time-setup-by-hand)
1. Create the database and copy its id into `env.production.d1_databases[0].database_id`.
```bash
cd apps/worker
npx wrangler whoami
npx wrangler d1 create dectog # add --location to pin a region
```
2. Set the secrets. `JWT_SECRET` and `IP_SALT` are mandatory; `BOOTSTRAP_TOKEN` is needed once.
```bash
npx wrangler secret put JWT_SECRET --env production # openssl rand -hex 32
npx wrangler secret put IP_SALT --env production # openssl rand -hex 32
npx wrangler secret put BOOTSTRAP_TOKEN --env production # any random string; deleted after step 6
# optional
npx wrangler secret put RESEND_API_KEY --env production
npx wrangler secret put GOOGLE_CLIENT_ID --env production
npx wrangler secret put GOOGLE_CLIENT_SECRET --env production
```
Without `IP_SALT` a production Worker throws (`IP_SALT is not set`, naming the `wrangler secret put` command) on the first request that hashes an IP: every link tap, install, app open and beacon. The Google redirect URI to register is `/api/auth/google/callback`.
3. Apply the migrations to the remote database.
```bash
npx wrangler d1 migrations apply DB --remote --env production
```
4. Build the dashboard’s static export. The production assets binding reads `apps/web/out`.
```bash
(cd ../web && npm ci && npm run build:static) # NEXT_OUTPUT=export next build
```
5. Deploy.
```bash
npx wrangler deploy --env production
```
Wrangler names the Worker `dectog-worker-production` and attaches the two routes, the crons and the assets.
6. Create the owner account. The body form is the only one production accepts.
```bash
curl -s -X POST https://app.example.com/api/admin/bootstrap \
-H "x-bootstrap-token: $BOOTSTRAP_TOKEN" \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"","username":"acme"}'
# {"ok":true,"email":"you@example.com","username":"acme","api_key":"dtg_…","sdk_key":"dtg_pub_…","demo":false}
```
The account key is shown only here: DecTog keeps its hash and prefix, and **Regenerate** in Settings replaces it. The SDK key is listed again in Settings (its copy was encrypted with the `JWT_SECRET` you set in step 2). Status codes and validation messages are on the [Local quick start](/self-hosting/local-quick-start/). Then remove the token; with `ENVIRONMENT=production` and no token, every further bootstrap call is `403`.
```bash
npx wrangler secret delete BOOTSTRAP_TOKEN --env production
```
7. Sign in at `https://app.example.com/login`, register your app under Apps (bundle id, Team ID, package, SHA-256 fingerprints, store URLs) and use the Verify buttons, which fetch `https://.example.com/.well-known/apple-app-site-association` and `assetlinks.json` as a device would. See [Apps](/dashboard/apps/) and [Platform setup](/sdk/platform-setup/).
## Every release
[Section titled “Every release”](#every-release)
Run these in order from `apps/worker`:
1. `git pull`, then `npm ci` here and in `../web`.
2. `npm test` and `npm run typecheck` (worker), `npm run typecheck` (web). Optional, recommended.
3. `npx wrangler d1 migrations apply DB --remote --env production`. Applying a migration while the old Worker is still live is safe when it only adds nullable or defaulted columns and indexes; the old Worker ignores columns it does not know. The command prints which files it applied; `npx wrangler d1 migrations list DB --remote --env production` shows what is pending.
4. `(cd ../web && npm run build:static)` so the deploy ships the current dashboard. Skipping this deploys the Worker with whatever `apps/web/out` already holds.
5. `npx wrangler deploy --env production`.
6. The smoke checks below.
Do not use the npm scripts for production
`npm run deploy` runs plain `wrangler deploy`, which reads the top-level (development) configuration: `ROOT_DOMAIN=localhost:8787`, `LINK_URL_MODE=path`, `WEB_URL=http://localhost:3000`, no `ENVIRONMENT`, no routes, no assets binding and the placeholder D1 id `local-dev`. It never touches your production routes, but it creates or updates a separate `dectog-worker` Worker with that configuration. `npm run db:migrate:remote` is safe: it runs `wrangler d1 migrations apply DB --remote --env production`. For anything else, pass `--env production` to `wrangler` directly.
## Smoke checks
[Section titled “Smoke checks”](#smoke-checks)
| Command | Expected |
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `curl -s https://app.example.com/healthz` | `{"ok":true,"service":"dectog","version":"v1","host":"app.example.com"}` |
| `curl -s https://app.example.com/readyz` | `200 {"ok":true,"database":true,"migrations":{…,"missing":[]}}`. A `503` names the migrations still to apply: run `npx wrangler d1 migrations apply DB --remote --env production`. |
| `curl -s https://app.example.com/api/v1/links` | `401 {"error":{"code":"missing_api_key","message":"Missing API key",…}}` |
| `curl -sI https://example.com/` | `200`, `text/html` (the landing page from the static export) |
| `curl -sI https://app.example.com/login` | `200`, `text/html` (the dashboard’s login page) |
| `curl -sI https://app.example.com/` | `302` to `/login` |
| `curl -sI https://no-such-user.example.com/` | `302` to `UNKNOWN_HOST_REDIRECT` (or `404` when it is unset) |
| `curl -sI https://.example.com/` with a desktop User-Agent | `200` QR page, or `302` to a desktop redirect or fallback URL if the link has one |
| `curl -s https://.example.com/.well-known/apple-app-site-association` | The AASA JSON once an app with bundle id and Team ID exists, otherwise `404 {"error":{"code":"not_found","message":"Configuration not found"}}` |
| `npx wrangler deployments list --env production` | The new version at the top |
| Cloudflare dashboard, Worker, Settings, Triggers | Cron triggers `17 3 * * *` and `7 * * * *` |
`npx wrangler tail --env production` streams the Worker’s console output, including the password-reset link when `RESEND_API_KEY` is unset and the warnings from webhook deliveries and Apple Search Ads calls.
## Rollback
[Section titled “Rollback”](#rollback)
```bash
npx wrangler deployments list --env production
npx wrangler rollback --env production # or: rollback --env production
```
D1 data persists across rollbacks. A rollback does not undo migrations, and migrations are never reverted: an older Worker then runs against a newer schema. Migrations that add nullable columns or indexes are harmless for it; a migration that rebuilds a table or adds a `NOT NULL` column without a default is not, so read the migration you are rolling back across. The list is on [Database and migrations](/self-hosting/database-and-migrations/).
## What `ENVIRONMENT=production` changes
[Section titled “What ENVIRONMENT=production changes”](#what-environmentproduction-changes)
| Behavior | Without it | With it |
| -------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Bootstrap token | Required only when `BOOTSTRAP_TOKEN` is set | Always required; with no token configured every bootstrap call is `403 Bootstrap not permitted` |
| Demo seed (empty-body bootstrap) | Allowed on an empty database | `403 Demo seed is disabled in production` |
| `IP_SALT` missing | Falls back to `dtg-default-salt` with a console warning | Throws on every request that hashes an IP |
Nothing else reads `ENVIRONMENT`. Signup stays open unless `OPEN_SIGNUPS="0"` is set, CORS follows `WEB_URL`, and the `Secure` cookie flag follows the request scheme (`https`), not this variable. The demo-login hint on the login page disappears with the static export (`NEXT_OUTPUT=export`), not with this variable.
## Strings that stay hard-coded
[Section titled “Strings that stay hard-coded”](#strings-that-stay-hard-coded)
`BRAND_NAME` renames the product on the link pages (page titles, “This … link does not exist”, the `Go to …` button and the “Powered by” footer of the QR page) and in the password-reset email. A few DecTog references stay in code: the `Go to` button’s target (`https://dectog.com/`), the reset email’s `Reply-To: hello@dectog.com` and default sender (`apps/worker/src/email.ts`), and the marketing pages of the Next.js app. Change them in the source before you build if they matter to you. The signup form’s link-domain hint follows your `ROOT_DOMAIN`.
# Local quick start
> Run the DecTog worker, a local D1 database and the dashboard on your machine, seed demo data and tap your first link.
A local DecTog is two processes. The Cloudflare Worker runs on port 8787 with a local D1 database under `apps/worker/.wrangler/state`: it serves the REST API, the admin API, the redirect engine and the well-known files. The Next.js app runs on port 3000 and proxies every `/api/*`, `/r/*` and `/.well-known/*` request to the worker (`apps/web/next.config.ts`), so the dashboard’s session cookie stays same-origin. You do not need a Cloudflare account; `wrangler dev` runs everything locally.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* Node 22.12 or newer, and npm.
* A clone of the server repository: `git clone https://github.com/DecTog/dectog`.
* Flutter 3.29 or newer, only if you also run the example app. See [Testing](/sdk/testing/).
## Start the worker
[Section titled “Start the worker”](#start-the-worker)
1. Install dependencies and create the local database schema.
```bash
cd apps/worker
npm install
npm run db:migrate # wrangler d1 migrations apply DB --local
```
`db:migrate` applies every file in `apps/worker/migrations/` in filename order to the local D1 database. Re-run it whenever you pull new migrations.
2. Create `.dev.vars` from the example and fill in the secrets.
```bash
cp .dev.vars.example .dev.vars
```
apps/worker/.dev.vars
```text
JWT_SECRET=
IP_SALT=
BOOTSTRAP_TOKEN=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```
Set `JWT_SECRET` before you bootstrap: it signs the dashboard session and also encrypts the viewable copy of each SDK and site key, so a key created while it is empty can only be shown as a mask. Leave `IP_SALT` empty and the worker falls back to `dtg-default-salt` with a console warning (production refuses to start a request without it). Leave `BOOTSTRAP_TOKEN` empty and the bootstrap call needs no header. The Google fields stay empty unless you test Sign in with Google (the redirect URI is `http://localhost:3000/api/auth/google/callback`).
3. Start the worker.
```bash
npm run dev # wrangler dev --port 8787
```
`curl -s http://localhost:8787/healthz` returns `{"ok":true,"service":"dectog","version":"v1","host":"localhost:8787"}`.
## Bootstrap the database
[Section titled “Bootstrap the database”](#bootstrap-the-database)
`POST /api/admin/bootstrap` creates the first account. It works only while the `users` table is empty and has two forms.
* Demo seed (empty body)
```bash
curl -s -X POST http://localhost:8787/api/admin/bootstrap
# with BOOTSTRAP_TOKEN set in .dev.vars:
curl -s -X POST -H 'x-bootstrap-token: ' http://localhost:8787/api/admin/bootstrap
```
```json
{"ok":true,"email":"demo@dectog.local","username":"demo","api_key":"dtg_…","sdk_key":null,"demo":true}
```
The demo password is `demo1234`; it is not in the response. The demo account’s key is stored hash-only, like every account key, so Settings shows only its prefix; **Regenerate** replaces it and shows the new key once. Copy the key from this response if you want to use it with the SDK.
* One owner account (JSON body)
```bash
curl -s -X POST http://localhost:8787/api/admin/bootstrap \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"a-long-password-12","username":"acme"}'
```
```json
{"ok":true,"email":"you@example.com","username":"acme","api_key":"dtg_…","sdk_key":"dtg_pub_…","demo":false}
```
This creates exactly that account with role `owner` and no demo data. The account key is shown only here; Settings keeps its prefix. The SDK key is listed again in Settings, because `JWT_SECRET` was set.
The exact rules, from `apps/worker/src/api_admin.ts` and `bootstrap.ts`:
| Rule | Response |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `BOOTSTRAP_TOKEN` is set (non-empty), or `ENVIRONMENT=production` | The `x-bootstrap-token` header must equal the token. Otherwise `403 {"error":{"code":"forbidden","message":"Bootstrap not permitted"}}`. In production with no token configured every call is 403. |
| Empty body, or a body that is not valid JSON | Demo seed. In production: `403 {"error":{"code":"forbidden","message":"Demo seed is disabled in production — provide email, password and username"}}`. |
| Body with any of `email`, `password`, `username` | All three are required. `400` with one of: `email, password and username are required`, `email is not a valid address`, `password must be at least 12 characters`, `username must be 3-32 lowercase letters, digits or hyphens, starting and ending alphanumeric`, `username '' is reserved` (reserved: `app www api links dashboard admin mail static cdn`). Email and username are trimmed and lowercased. |
| Any user already exists | `409 {"error":{"code":"conflict","message":"Already bootstrapped"}}` (both forms). |
| Success | `201 {"ok":true,"email","username","api_key","demo"}`. |
Bootstrap has no rate limit. The owner password rule (at least 12 characters) differs from the signup rule (8 to 72 characters with an upper-case letter, a lower-case letter and a digit); see [Account and sign-in](/dashboard/account-and-sign-in/).
### What the demo seed contains
[Section titled “What the demo seed contains”](#what-the-demo-seed-contains)
`apps/worker/src/seed.ts` uses a fixed random seed, so every seed looks the same apart from the weekday pattern.
| Item | Content |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| User | `demo@dectog.local` / `demo1234`, username `demo`, role `owner`; its account key is never shown (Settings → Regenerate gives you one) |
| App | “Demo App”: iOS bundle `com.example.demoapp`, Team ID `8H7C9JL2K3`, App Store URL `https://apps.apple.com/app/id6470000000`; Android package `com.example.demoapp`, one placeholder SHA-256 fingerprint (`AA:BB:CC:…`), Play URL `https://play.google.com/store/apps/details?id=com.example.demoapp`. No schemes, fallback URL, social preview, webhook, interstitial, tap-to-store, strict mode or OEM store URLs. |
| Links | 6, all bound to the app: `welcome` (direct, 18 %), `launch-ph` (Product Hunt, 22 %), `social-x` (X, 16 %), `reddit-ama` (Reddit, 12 %), `tiktok-demo` (TikTok, 14 %), `newsletter` (Newsletter, 18 %). Targets are `https://example.com/`. The campaign is stored in both `links.campaign` and `metadata.campaign`. |
| Clicks | About 1,880 over the last 90 days: a base of `14 + round(0.12 × days since start)` per day, weekends at 55 %, plus a spike of +45 / +220 / +90 on days 31, 30 and 29 ago (the “Product Hunt launch”). Devices 48 % iOS, 37 % Android, 15 % desktop. Countries US 32 %, DE 12 %, GB 11 %, IN 10 %, FR 7 %, BR 7 %, JP 6 %, CA 6 %, AU 5 %, NL 4 %. About 400 distinct fake IP hashes (`seed_`), fake `10.x.x.x` raw IPs, `utm_source` and `utm_campaign` set to the campaign name with `utm_medium=link`, `origin` `redirect`. |
| Installs | About 7 % of clicks, roughly 130. About 60 % are attributed: all Android, `match_source` `referrer`, `referrer_source` `google_play`, `install_referrer` `dtg_click=`, `match_confidence` `guaranteed`. About 40 % are organic: all iOS. About 8 % are reinstalls of an earlier seeded device. `ip_hash` is null on every seeded install, so seeded installs never take part in fingerprint matching. |
| Not seeded | Temporary links, events, customers, revenue, device pings, app opens, blog posts, categories, contact messages. |
## Start the dashboard
[Section titled “Start the dashboard”](#start-the-dashboard)
```bash
cd apps/web
npm install
npm run dev # next dev -p 3000
```
Sign in at `http://localhost:3000/login` with `demo@dectog.local` / `demo1234` (or the owner account you bootstrapped). The dev build shows a demo-login hint on that page; the static export used in production does not. The dashboard is at `http://localhost:3000/dashboard`.
`npm run serve` (`next build && next start -p 3000`) runs a production-style server; restart it after every rebuild. Set `WORKER_ORIGIN` if your worker is not on `http://localhost:8787`.
## Open a link
[Section titled “Open a link”](#open-a-link)
In local development `LINK_URL_MODE` is `path` and `ROOT_DOMAIN` is `localhost:8787`, so the URLs the API and the dashboard generate look like `http://localhost:8787/r/demo/welcome`. The worker also answers the other forms:
| URL | Notes |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `http://localhost:8787/r/demo/welcome` | Path form. Works on every host, in every environment. |
| `http://localhost:8787/?user=demo/welcome` | Query form. Works on every host except `APP_HOST`. |
| `http://demo.localhost:8787/welcome` | Subdomain form. `*.localhost` counts as a tenant host and browsers resolve it to the loopback address. |
| `http://localhost:8787/api/v1/resolve/demo/welcome` | Public resolve endpoint, no auth. See [Links](/api/links/). |
| `http://localhost:8787/.well-known/apple-app-site-association?u=demo` | AASA. Locally `?u=` is required: subdomain resolution of well-known files is off while `ROOT_DOMAIN` starts with `localhost`. |
| `http://localhost:8787/.well-known/assetlinks.json?u=demo` | Asset links, same rule. |
A desktop browser lands on the branded QR page (the demo app has no fallback URL, so there is nowhere else to go). A phone, or a mobile User-Agent, gets a 302 to the store with the click tag:
```bash
curl -sI -A 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/UP1A) AppleWebKit/537.36 Chrome/124.0 Mobile Safari/537.36' \
http://localhost:8787/r/demo/welcome
# location: https://play.google.com/store/apps/details?id=com.example.demoapp&referrer=dtg_click%3D%26dtg_l%3Ddemo%2Fwelcome
```
Each of these requests records a click; the Overview updates within its 15 s refresh. The full decision order is on [How a tap is routed](/links/how-a-tap-is-routed/).
## Android emulator
[Section titled “Android emulator”](#android-emulator)
The emulator cannot reach `localhost`; it reaches the host machine at `10.0.2.2`. Start the worker with
```bash
npm run dev:android # wrangler dev --port 8787 --var PUBLIC_BASE_URL:http://10.0.2.2:8787
```
so that generated link URLs use `http://10.0.2.2:8787/r//`. Only `PUBLIC_BASE_URL` changes; `ROOT_DOMAIN` stays `localhost:8787`. Point the example app at the same origin (`baseUrl: 'http://10.0.2.2:8787'`) and trigger links with `adb`; see [Testing](/sdk/testing/).
## Useful commands
[Section titled “Useful commands”](#useful-commands)
```bash
cd apps/worker
npm test # vitest, 28 files, isolated D1 per test
npm run typecheck # tsc --noEmit
cd ../web && npm run typecheck
```
Reset the local database (bootstrap answers 409 once any user exists):
```bash
cd apps/worker
rm -rf .wrangler/state
npm run db:migrate
```
Run the scheduled jobs locally. `npm run dev` does not fire cron triggers; start wrangler with `--test-scheduled` and call the test endpoint:
```bash
npx wrangler dev --port 8787 --test-scheduled
curl "http://localhost:8787/__scheduled?cron=17+3+*+*+*" # daily sweep
curl "http://localhost:8787/__scheduled?cron=7+*+*+*+*" # hourly: rollups, retries, exports, custom domains
```
What each trigger does is on [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/). When you are ready for a real domain, continue with [Deploy to Cloudflare](/self-hosting/deploy/).
# Privacy and data
> What a DecTog server stores about visitors, devices and customers, for how long, what the SDK sends, what a tenant can delete, and what to put in a privacy policy.
This page lists the personal data a self-hosted DecTog handles, table by table, with the retention each column gets from the daily sweep (`RETENTION_DAYS`, default 400, or an account’s shorter window). The sweep itself is specified on [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/). “Retention” means the value is set to `NULL` once the row is older than the cutoff; “forever” means the sweep never blanks it, so it lasts as long as its row. Rows of `clicks`, `app_installs`, `events` and `app_opens` older than the cutoff are deleted outright once the daily rollups hold their day, which leaves only the per-link daily counts; the one exception is an event with a store transaction id (a purchase, renewal or refund), kept with its personal columns nulled so its transaction is still known.
## IP addresses
[Section titled “IP addresses”](#ip-addresses)
Every request’s address is read from `CF-Connecting-IP` (else the first `X-Forwarded-For` entry, else `0.0.0.0`).
* **Hashed:** `sha256(":")`. IPv6 addresses are cut to their /64 prefix before hashing, because privacy extensions rotate the interface id between the browser tap and the app’s first launch; IPv4-mapped addresses are hashed as the plain IPv4, and addresses whose /64 prefix is all zeros (loopback) are hashed in full. The hash is the join key for fingerprint matching, the beacon, the `install_velocity_ip` and `click_storm_ip` rules, the per-IP rate limits and the Overview’s “unique visitors”. It is only as private as `IP_SALT`.
* **Raw:** stored by default. The plain address is kept in `clicks.ip_raw`, `app_installs.ip_raw` and `events.ip_raw` until retention, where the Customers detail page and the admin API show it; a request with no address (the `0.0.0.0` fallback) stores none. A deployment that sets `STORE_RAW_IP=0` keeps no raw address. Matching, fraud rules and rate limits never use it. The hosted service keeps it.
| Table | Hashed | Raw | Until |
| -------------- | ---------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `clicks` | `ip_hash` | `ip_raw`, unless `STORE_RAW_IP=0` | the row is deleted at retention (nulled while the daily rollups catch up) |
| `app_installs` | `ip_hash` | `ip_raw`, unless `STORE_RAW_IP=0` | the same |
| `events` | none | `ip_raw`, unless `STORE_RAW_IP=0` (events from the web pixel or a store notification keep none) | the same |
| `app_opens` | `ip_hash` | none | the same |
| `rate_limits` | in the key | none | rows deleted after the window passes; with the Rate Limiting bindings (the default config) only the sign-in and reset limits use it |
Bots (User-Agent list, or a Cloudflare bot score below 30) never produce a click row. Link taps set no cookies; the only cookies are the dashboard session and the two Google sign-in cookies.
## Clicks
[Section titled “Clicks”](#clicks)
| Data | Column | Source | Until |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Device family, OS label, browser | `device_type`, `os`, `browser`, `os_major`, `os_minor` | User-Agent; `Sec-CH-UA-Platform-Version` for the Android version, Safari’s `Version/` token for the iOS version | forever |
| Device model | `device_model` | `Sec-CH-UA-Model` client hint or the WebView `Build/` token, lower-cased; from the SDK on in-app clicks | retention |
| Raw User-Agent | `ua_raw` | header, cut to 512 characters | retention |
| Language | `accept_language` (128 characters), `lang` (primary tag) | `Accept-Language` | `accept_language` at retention; `lang` forever |
| Geo and network | `country`; `region`, `city`, `asn`, `as_org` (128 characters) | Cloudflare’s `cf` object | `country` forever; the rest at retention |
| Screen profile | `screen_width`, `screen_height`, `pixel_ratio`, `max_touch_points`, `connection_type` | the beacon of the pages a phone gets instead of a plain redirect: the mobile interstitial, the iOS tap-to-store page, and the Facebook and Instagram page on Android | screen fields after `min(RETENTION_DAYS, 30)` days; `connection_type` forever |
| Device settings | `text_size` (iOS and iPadOS only), `dark_mode`, `high_contrast` (Increase Contrast), `reduce_motion` (Reduce Motion), `utc_offset_min` | the same beacon, from the browser’s media queries, the system body font and the page’s clock | with the screen fields, after `min(RETENTION_DAYS, 30)` days |
| Traffic source | `referrer` | `Referer` header reduced to its origin; null for the deployment’s own hosts | forever |
| Campaign | `utm_*`, `link_params` (`dec_*` values) | the link URL’s query string | forever |
| Bot score | `bot_score` | Cloudflare bot management, scores of 30 and above | forever |
| Page button | `cta`, `cta_at` | the link page’s beacon: which store, app or website button was tapped first | forever |
| SDK-reported opens | `device_id`, `session_id`, `client_click_time`, `launch_referrer` | the SDK’s `POST /api/v1/clicks` | `device_id` and `launch_referrer` at retention; the rest forever |
## Installs
[Section titled “Installs”](#installs)
| Data | Columns | Source | Until |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Device id | `device_id` | the SDK’s device tier: IDFV on iOS (persisted in the Keychain by default) or `ANDROID_ID` | retention |
| Hardware | `device_model`, `device_manufacturer`, `device_kind`, `cpu_type`, `os_build` | device tier | retention; `cpu_family` (derived) forever |
| Screen | `screen_width`, `screen_height`, `screen_density` | device tier | retention |
| Device settings | `text_size` (iOS only), `dark_mode`, `high_contrast` (iOS only), `reduce_motion` | device tier | retention |
| Network | `connectivity_type`, `carrier_mcc`, `carrier_mnc` | device tier | retention |
| ASN | `asn` | Cloudflare | forever |
| OS, locale | `os`, `os_version`, `os_major`, `os_minor`, `language`, `lang`, `timezone` | always sent | forever |
| UTC offset | `utc_offset_min` | always sent, like the time zone | retention, with the device tier |
| App | `app_platform`, `app_version`, `app_build_number`, `package_name`, `installer_package`, `install_time`, `update_time`, `first_install_ts`, `install_state`, `is_physical_device` | always sent | forever |
| WebView User-Agent | `web_user_agent` (512 characters) | SDK | retention |
| Store referrer | `install_referrer` (2048 characters), `referrer_store`, `referrer_status`, the click and install-begin timestamps | Play, Huawei, Samsung, Xiaomi or vivo referrer | forever. The string carries `dtg_click=` and `dtg_l=/`, plus whatever else the store put in it. |
| Meta referrer | `meta_referrer` (512 characters), `meta_referrer_ts`, `meta_referrer_is_ct` | Meta’s on-device provider, only when the app has a Facebook App ID | `meta_referrer` at retention; the rest forever |
| Copied link | `clipboard_slug` | the pasteboard; only the slug and click id are extracted, the clipboard text itself is never stored | forever |
| Apple Search Ads | `asa_token`; `asa_attribution` (campaign, ad group, keyword and ad ids, dates, country) | AdServices, only with `DectogCollection.appleSearchAdsToken` | the token is nulled as soon as the status settles, or at retention; the record forever |
| Verdict and fraud | `matched*`, `match_*`, `referrer_source`, `candidate_count`, `routing_click_id`, `fp_shadow_*`, `fraud_flags` | the Worker | forever |
| Free-form | `metadata` | whatever the app put in the request’s `metadata` | forever |
### Advertising identifiers
[Section titled “Advertising identifiers”](#advertising-identifiers)
`POST /api/v1/app-installs` accepts `ad_id` and `ad_tracking_limited` and stores them in `ad_identifiers`, keyed by install id. No query joins that table to `app_installs` or to `device_id`; it is written once and deleted by the sweep. flutter_dectog itself reads neither the GAID nor the IDFA; these fields are only sent by apps that add the `flutter_dectog_adid` package, and only when the user allowed tracking (otherwise `ad_tracking_limited` is `true` and no id is sent). See [Device data and privacy](/sdk/device-data-and-privacy/).
### What the SDK sends per tier
[Section titled “What the SDK sends per tier”](#what-the-sdk-sends-per-tier)
The device tier (`DectogCollection.deviceTier`) is on by default and `persistDeviceId` is `true` by default. With the device tier on, every install registration carries the device id, hardware, screen, settings and network fields above, and every in-app click the device id and model. With it off, those fields are absent: the server then cannot detect reinstalls by device, the fingerprint has no model or screen signal, and `GET /api/v1/attribution` works by `install_id` only. The OS, locale (the UTC offset included), app and referrer fields are sent regardless. The pasteboard read (`DectogCollection.pasteboard`) is on by default on iOS while one of the account’s pages copies the link (tap-to-store or the interstitial), off on Android, unless the dashboard or `collect` says otherwise; on iOS 16 and later it triggers the system paste notice; the Apple Search Ads token is off by default.
## Customers and events
[Section titled “Customers and events”](#customers-and-events)
* `customers.name` (200 characters), `email` (254) and `phone` (32) are stored **in plain text**, exactly as your app sends them in `identify` events (`apps/worker/src/api_events.ts`). A later identify that sends a field overwrites it; a field it omits keeps its old value. Only `first_install_id` is first-seen. They are nulled once the customer’s `updated_at` is older than the retention cutoff. `customer_id`, the identifier your app chose, is kept forever.
* Event rows are deleted at retention once the rollups hold their day. Until then a row past the cutoff keeps `type`, `event_name`, `revenue`, `currency`, `install_id` and `matched_slug`, and has `device_id`, `customer_id`, `metadata` (free JSON from your app) and `ip_raw` nulled. An event with a `transaction_id` is never deleted by the sweep: it stays in that nulled form, so a purchase restored after the window counts once and a refund finds its purchase. It goes with an erasure of its device or customer, or a permanent delete of its link.
* `device_days` rows (device id, the UTC day, app version, OS, the first ping’s time) are deleted at retention. A device has one per day it started the app, not one per start.
* `app_sessions` rows (one per app session with screen views: the device, install, customer and link open the session is first known by, its first screen’s time, the screen count and the first 50 screens with their times) are deleted at retention by the session’s start.
* `app_opens` rows go the same way: deleted at retention once the rollups hold their day; until then they keep the slug and time and have `device_id`, `install_id`, `click_id` and `ip_hash` nulled.
The dashboard masks the email in the Customers list (`e****@domain`) but shows it in full on the detail page, together with the attributed click’s geo and network, and its raw IP unless the deployment sets `STORE_RAW_IP=0`. See [Customers](/dashboard/customers/).
## Rejected requests
[Section titled “Rejected requests”](#rejected-requests)
When a `/api/v1` call is refused as malformed, the server stores the account, the route, the error code and message, the SDK build, and the SHA-256 and size of the body, never the body. The rows are deleted after 30 days.
## Store notifications
[Section titled “Store notifications”](#store-notifications)
`store_notifications` logs every App Store and Google Play notification an app’s URL receives: store, notification id, type, subtype, environment, transaction id, outcome and time. No customer data. Rows are deleted after 90 days; the revenue rows they wrote stay like any other event. `app_store_endpoints` keeps each app’s URL token and the Pub/Sub service account email until the app or its endpoint is deleted.
## Data that leaves the server
[Section titled “Data that leaves the server”](#data-that-leaves-the-server)
* **Webhooks:** the `app.installed` and `app.install_updated` deliveries include `device_id`, `device_model`, `device_manufacturer`, OS, connectivity, carrier codes, `ip_hash`, the matched link and click params, fraud flags and the Apple Search Ads record; `event.*` deliveries include the customer id and the event’s metadata; `click.recorded` the click’s `ip_hash`, country and language. Every delivery is kept in `webhook_deliveries` with that data and the start of your endpoint’s response for 30 days, then deleted by the daily job; neither the retention sweep nor a permanent link delete removes it sooner. Webhook secrets are stored encrypted under `JWT_SECRET` and never returned after they are shown once. See [Webhooks](/api/webhooks/).
* **Exports:** the [raw export](/api/export/) and the dashboard’s CSV hand the tenant its own clicks, installs, opens and events, without the IP (raw or hashed) and the user agent. A CSV too large to stream is written to the `EXPORTS` R2 bucket, when one is configured, behind a signed link, and deleted after 7 days; its job row (the query, not the data) goes a week later.
* **Apple:** the AdServices token is posted to `api-adservices.apple.com`.
* **Google Play’s device list:** at most daily, the Worker downloads `https://storage.googleapis.com/play_public/supported_devices.csv` to name device models. The request carries no data about you or your users.
* **European Central Bank:** once a day the Worker downloads `https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml` (the euro reference rates) to convert revenue. The request carries no data about you or your users.
* **Resend:** the password-reset email (recipient address, reset link) when `RESEND_API_KEY` is set.
* **Google:** the OAuth code exchange when Sign in with Google is configured, and, when an app receives Play notifications, a download of Google’s public signing keys (`https://www.googleapis.com/oauth2/v3/certs`) to verify the push tokens. That request carries no data.
* **Cloudflare:** everything is processed in Workers and stored in D1. D1 chooses a region when the database is created; `wrangler d1 create --location` pins it.
* **Visitors’ browsers contact only the deployment.** Link pages and the dashboard load their fonts (Outfit, Inter, Fira Code, all SIL OFL) and country flags from the deployment itself; no page asks Google Fonts, a CDN or any other host for anything.
## Accounts
[Section titled “Accounts”](#accounts)
`users` keeps the email, a PBKDF2-SHA256 password hash (100,000 iterations), the username, the SHA-256 of the account key with its 8-character prefix (never the key itself), the role and the Google `sub`. Nothing in `users` is ever anonymized. `password_resets` stores token hashes with expiry and use time, never pruned. `contact_messages` stores name, email, subject and message from the public form until an owner deletes them.
## Teams
[Section titled “Teams”](#teams)
`invites` keeps the invited email address and role, with the SHA-256 of the link’s token, after it is accepted or expires; `memberships` keeps who belongs to which account. Neither is anonymized or pruned.
## Audit log
[Section titled “Audit log”](#audit-log)
`audit_events` records every change to an account’s configuration, every sign-in, wrong password, sign-out and password reset, and every CSV export ([Settings: Audit log](/dashboard/settings/#audit-log)). Each entry holds the email of the person who acted (or the key’s prefix and name), the salted IP hash, the user agent and the request id. It holds no secret: fields that look like one are stored as `[redacted]`, and webhook URLs only as their origin. Entries are never anonymized or pruned: `RETENTION_DAYS` does not apply to them, and they are kept as long as the account.
## What a tenant can delete
[Section titled “What a tenant can delete”](#what-a-tenant-can-delete)
| Action | Effect |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Delete a link (dashboard **Delete**, `DELETE /api/v1/links/:slug`) | Nothing at once: the link is marked deleted and stops answering. Thirty days later the daily job sets the personal columns of its clicks and app opens to `NULL` (see [Scheduled jobs](/self-hosting/scheduled-jobs-and-retention/#deleted-links-daily)); the counts stay. |
| Delete a link permanently (dashboard **Delete permanently**, `?permanent=true`) | In one batch: events whose `matched_slug` is the link or whose install was attributed through its clicks; the link’s app opens and referrals; `customers.first_install_id` cleared where it pointed at those installs; the attributed installs; every click on the link; the link row. The data disappears from Overview and per-link analytics. |
| Delete a temporary link | The same two paths. |
| “Delete expired” temporary links | Only the `temp_links` rows; clicks and installs stay. |
| Delete an app | Its links fall back to the oldest remaining app; installs and clicks are untouched; its store notification URLs stop working. |
| Regenerate the API key | The old key stops working immediately. |
| Erase a device or a customer | See [A request from one person](#a-request-from-one-person). |
| Shorten the retention window (Settings → **Data retention**) | From the next daily run, the account’s raw rows older than its window are anonymized or deleted, as `RETENTION_DAYS` does for the deployment. |
There is no way through the dashboard or the API to delete an account or to rename a username.
## A request from one person
[Section titled “A request from one person”](#a-request-from-one-person)
When someone asks what you hold about them, or asks you to delete it, find their device or customer id (the Customers screen, or the install detail on Overview and Links) and use **Download everything stored (JSON)** or **Erase this device** / **Erase this customer** there. From a server, with the account key and the `dectog` helper from [Signing with curl](/api/overview/#signing-with-curl):
```bash
dectog GET /api/v1/devices//export > device.json
dectog DELETE /api/v1/devices/
```
`/api/v1/customers//export` and `DELETE /api/v1/customers/` do the same for a customer (URL-encode an id with `/`, spaces or other reserved characters). An SDK key gets `403 account_key_required`.
**The export** is a JSON file: the device’s installs (device, OS, app version, screen, device settings, network, language, raw IP (none with `STORE_RAW_IP=0`), the attribution verdict and fraud flags), its taps (link, time, country, region, city, browser, user agent, device settings, referrer, UTM; a tap its install was credited to or routed by counts as its own), events, app opens, app starts (one per day), its app sessions with their screens, advertising ids and the referral that brought it. A customer’s export holds the customer record, its events, the referrals it made and received (without the other customers’ ids), its referral link, and its devices: each device only they used, exported in full, and each device another customer also used, by id only. Salted IP hashes, matching internals and the Apple Search Ads token are left out.
**Erasing a device** deletes, in one batch, its installs, events (by device id or install id), app opens, app starts, app sessions (by device id or install id), advertising ids, the referrals that name it, and the webhook deliveries that mention the device, one of its installs or one of its taps. Its taps (by device id, and the taps its installs were credited to or routed by) keep their link, time, country, platform and UTM, so link counts stay, and lose the device id and model, IP, user agent, language, region, city, network, referrer, screen profile, device settings (text size, dark mode, Increase Contrast, Reduce Motion, UTC offset), session and link parameters. Customers that pointed at its installs keep their record, with the install link cleared. On a device more than one customer used, the device’s events include the other customers’ events from it, and they go too; erase the customer instead to keep theirs.
**Erasing a customer** deletes the customer record, its events, the app sessions that named it and the referrals it made or received, and the webhook deliveries that name it, clears it from its referral link (which keeps routing taps, since it may be shared, but credits no one), then erases each device only that customer used, as above. A device another customer also used is kept, with that customer’s data; only the erased customer’s events on it go.
What stays, and for how long:
* **Counts.** The daily rollups hold totals per link and day, never a device or a person, and keep them.
* **The audit log** records the request: who erased or exported, when, the number of rows per table, and the first 12 hex characters of `sha256("::")`, never the id itself.
* **A tombstone** with that hash, for 8 days: an SDK keeps unsent events up to 7 days, so an event of the erased device or customer with a client timestamp before the erasure is acknowledged and dropped. An edge location that looked the app’s SDK key up shortly before the erasure still holds the account as it was, for up to `SDK_AUTH_CACHE_SECONDS` (60 by default; see [Configuration reference](/self-hosting/configuration-reference/)), and may store such an event in that minute; the daily sweep deletes it. Events made after the erasure are stored as new data.
* **CSV exports** made earlier keep their copy until the file expires, at most 7 days.
* **D1 Time Travel** can restore the database to any minute of the last 30 days (7 on the Workers Free plan; [Cloudflare’s Time Travel page](https://developers.cloudflare.com/d1/reference/time-travel/)), so erased rows stay restorable there until then. Nothing in DecTog restores them.
Deleting an install row frees its click for a future fingerprint match (the `NOT EXISTS` check in the candidate query), but the erased taps have no IP hash or device model left to match on.
## For your privacy policy
[Section titled “For your privacy policy”](#for-your-privacy-policy)
A self-hosted DecTog makes you the controller of this data. Items a policy and the store declarations usually need to cover, with where each comes from:
1. **Link taps:** IP address (hashed and, until retention, raw), country, region and city, ASN, User-Agent and client hints, language, referrer origin, UTM parameters; on the interstitial and tap-to-store pages also the screen profile, text size, dark mode, Increase Contrast, Reduce Motion and UTC offset; purpose: routing, install attribution, fraud detection, analytics.
2. **App installs and opens:** the vendor-scoped device identifier (IDFV persisted in the Keychain, or `ANDROID_ID`), hardware model and manufacturer, OS and version, screen metrics, text size, dark mode, Increase Contrast and Reduce Motion, UTC offset, connectivity and carrier codes (Android), app version and install dates, IP address; purpose: install attribution, reinstall detection, analytics. Declare the persisted identifier in the App Store privacy answers and the Play Data safety form.
3. **Store install referrers** (Play, Huawei, Samsung, Xiaomi, vivo) and, if you set a Facebook App ID, Meta’s on-device install referrer.
4. **Pasteboard access on iOS** on first launch (system notice on iOS 16 and later), if you leave the pasteboard read at its default and turn on iOS tap-to-store or the interstitial.
5. **Apple Search Ads attribution token**, if `collect` names `appleSearchAdsToken`.
6. **Advertising identifier** (IDFA after ATT, GAID), only with the `flutter_dectog_adid` package.
7. **Customer identity**: whatever your app sends in `identify` (name, email, phone, your customer id) and in event metadata, stored in plain text.
8. **Retention:** clicks, installs, events and app opens deleted after `RETENTION_DAYS` (400 by default), keeping only daily per-link counts; other personal columns anonymized then; the screen profile and device settings of taps after 30 days, Apple tokens as soon as resolved.
9. **Your website, with [`dectog.js`](/sdk/web/):** first-party cookies `dtg_click` (the click a visitor landed with, 30 days), `dtg_consent` (their answer, a year) and `dtg_banner`; the leads, purchases and custom events your pages send, with no IP stored. In the EU and EEA, the UK and Switzerland, and for browsers that send Global Privacy Control, nothing is stored before consent.
10. **Consent in your app:** with `DectogConfig(enabled: false)` the SDK sends nothing and reads no pasteboard until your app calls `setEnabled(true)`; events tracked meanwhile wait on the device (at most 7 days). See [Waiting for consent](/sdk/device-data-and-privacy/#waiting-for-consent).
11. **Processors:** Cloudflare (Workers, D1), plus Apple, Resend, Google and your own webhook endpoint where used.
The device settings in items 1 and 2 need particular care. Text size, dark mode, Increase Contrast, Reduce Motion and the UTC offset are device configuration, and Apple’s user-privacy rules for developers prohibit deriving data from a device’s configuration to identify it; DecTog compares them one by one for 0 points and combines them into no identifier, but the App Store review risk is yours. Increase Contrast, Reduce Motion and a large text size can suggest a disability, which EU data-protection law may treat as health data. An app that cannot carry this leaves the SDK’s device tier out or turns **Probabilistic match** off; the link pages still record the settings on the tap. See [Device data and privacy](/sdk/device-data-and-privacy/#device-settings).
Adjust `RETENTION_DAYS` on the [Configuration reference](/self-hosting/configuration-reference/) if 400 days is longer than your policy allows.
# Scheduled jobs and retention
> The two cron triggers, what each one runs, how the daily rollups are built, and exactly which rows and columns the daily retention sweep deletes, nulls or keeps.
The Worker has two cron triggers, declared identically at the top level and in `env.production` of `apps/worker/wrangler.jsonc`. Cloudflare evaluates cron expressions in UTC. The handler is `scheduled` in `apps/worker/src/index.ts`.
| Expression | Fires | Runs |
| ------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `7 * * * *` | Every hour at :07 | Apple Search Ads retry, the daily rollups, webhook retries, unfinished CSV exports and custom domain checks |
| `17 3 * * *` | Daily at 03:17 UTC | The same, then the expired temporary links’ delete, the deleted-link anonymization, the webhook delivery-log purge, the export-file purge, the exchange rates and, once the rollups are done, the retention sweep |
The code branches on the expression: `7 * * * *` returns after the Apple Search Ads pass and the rollups; any other expression runs everything. The jobs run inside `waitUntil`, so a failure in one does not stop the others, except that the retention sweep waits for the rollup build. There is no job-level logging beyond console warnings.
`wrangler dev` does not fire crons. Start it with `--test-scheduled` and call `http://localhost:8787/__scheduled?cron=17+3+*+*+*` (or `7+*+*+*+*`) to run a trigger by hand; see the [Local quick start](/self-hosting/local-quick-start/).
## Apple Search Ads retry (both triggers)
[Section titled “Apple Search Ads retry (both triggers)”](#apple-search-ads-retry-both-triggers)
`retryPendingAsaTokens` in `apps/worker/src/asa.ts`:
1. Selects up to 100 installs with `asa_status = 'pending'` and a non-null `asa_token`, oldest `asa_token_at` first (index `idx_installs_asa_pending`).
2. For each one, if the token arrived more than 24 hours ago (`ASA_TOKEN_TTL_MS`), the row is settled as `expired` without calling Apple. Apple answers 404 forever for such tokens.
3. Otherwise it makes exactly one `POST https://api-adservices.apple.com/api/v1/` with the token as a `text/plain` body and increments `asa_attempts`:
* `200` with `attribution: true`: status `resolved`, Apple’s record stored in `asa_attribution` (snake_case: `campaign_id`, `ad_group_id`, `keyword_id`, `ad_id`, `conversion_type`, `claim_type`, `click_date`, `impression_date`, `country_or_region`, `supply_placement`), and `app.install_updated` goes to the webhooks that take the install’s app.
* `200` with `attribution: false`: status `no_attribution`.
* `400`: status `invalid`.
* `404` (minted too recently): stays `pending`.
* `5xx`, a network error or a malformed body: stays `pending`.
4. Whenever a status is settled, `asa_resolved_at` is set and `asa_token` is nulled: the token is a one-time credential and is never kept.
More than 100 pending tokens are spread over successive passes. The three inline attempts the install request itself makes, five seconds apart, are described on [Attribution internals](/self-hosting/attribution-internals/).
## Daily rollups (both triggers)
[Section titled “Daily rollups (both triggers)”](#daily-rollups-both-triggers)
`buildRollups` (`apps/worker/src/rollups.ts`) fills `link_daily`: per account, link, UTC day and platform (`ios`, `android`, `desktop`, `other`), the clicks, first installs and the credited ones, app opens, leads, net revenue in the account’s currency, and the events credited to a link because it re-opened the app. Installs and events no link earned sit under the slug `''`. The rows are rebuilt from the raw rows, a whole day at a time and never on the request path, so a tap costs no extra write.
Each account has a `rollup_state` row: `through_day`, the last built day, and `frozen_through` (below). A run takes the accounts furthest behind and builds up to 40 steps of 31 days across them, so a new deployment, or an account with years of data, backfills itself over the next runs; nothing needs to be run by hand. Reads take every day after `through_day` from the raw rows, so the dashboard is exact while the rollups catch up.
A change to a raw row of a built day moves `through_day` back to the day before it, and the next run rebuilds from there: a late claim that credits an older install, a store notification that changes an amount or reverses a refund, an exchange rate that arrives late, a new re-engagement window on an app (every unfrozen day), a deleted app, a new account currency, and a permanent link delete, which also removes the link’s rollup rows.
What reads the rollups: the Overview’s clicks, installs, opens, leads and revenue, its clicks per day and per device, for windows over 7 days (which are whole UTC days, today included), and the lifetime installs, opens, leads and revenue of every link on the Links screen.
## Webhook retries (both triggers)
[Section titled “Webhook retries (both triggers)”](#webhook-retries-both-triggers)
`retryDueDeliveries` (`apps/worker/src/webhooks.ts`) sends again every pending delivery whose `next_attempt_at` has passed and whose webhook is on, oldest first, up to 300 per run, ten at a time with a 10-second timeout each. A failure schedules the next attempt 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours after the previous one (six attempts in all), then marks the delivery failed. Because the job runs hourly, the 5- and 30-minute retries happen at the next :07. A larger backlog waits for the next run. See [Webhooks](/api/webhooks/#delivery-and-retries).
## CSV exports (both triggers)
[Section titled “CSV exports (both triggers)”](#csv-exports-both-triggers)
`resumeExportJobs` (`apps/worker/src/exports.ts`) writes up to three large CSV exports that the request which asked for them did not finish: queued ones, and ones whose run held them for 15 minutes without finishing. Each starts the file over, at most three attempts in all. Only with an `EXPORTS` bucket. See [Export raw data](/api/export/#larger-exports).
The daily trigger also runs `purgeExportJobs`: a file is deleted from the bucket seven days after it was written (its job turns `expired`), and the job row a week after that.
## Custom domains (both triggers)
[Section titled “Custom domains (both triggers)”](#custom-domains-both-triggers)
`sweepCustomDomains` (`apps/worker/src/customdomains.ts`) asks Cloudflare (and DNS, for a domain waiting for its CNAME) about the custom domains not checked for ten minutes while pending and for a day once active, and deletes the hostnames of removed or moved domains whose 7-day grace has ended, 50 of each per run. A domain that failed and was never removed gets the same grace a week later. Only when custom domains are configured. See [Custom domains](/self-hosting/custom-domains/).
## Device names (daily)
[Section titled “Device names (daily)”](#device-names-daily)
`refreshDeviceModels` (`apps/worker/src/devicemodels.ts`) downloads Google Play’s supported-devices list (`storage.googleapis.com/play_public/supported_devices.csv`, about 5 MB) and stores the marketing names of the device models your installs reported in the last 400 days, never the whole list, in `device_models`. It runs when the last refresh is a week old, or a day old while installs report models the table lacks. A download that fails, is not the list or has fewer than 10,000 rows changes nothing. The Overview’s Top devices joins the names at read time.
## Webhook delivery log (daily)
[Section titled “Webhook delivery log (daily)”](#webhook-delivery-log-daily)
```sql
DELETE FROM webhook_deliveries WHERE created_at <
```
## Expired temporary links (daily)
[Section titled “Expired temporary links (daily)”](#expired-temporary-links-daily)
```sql
UPDATE temp_links SET deleted_at =
WHERE expires_at < AND expired_url IS NULL AND deleted_at IS NULL
```
A temporary link therefore keeps answering for up to a day after it expires: the redirect shows the 410 page, `GET /api/v1/resolve/:user/:slug` answers `410 link_expired`, and `GET /api/v1/temp-links/:slug` still works. A link with an expired URL is never deleted this way: it keeps sending taps there until its owner deletes it. After the delete both answer 404 and the link leaves the lists (`?include_deleted=1` lists it). It is a soft delete, like the dashboard’s **Delete**: the link’s clicks, installs and events stay, and its slug stays reserved (`409` with `deleted: true`) until the link is restored or deleted permanently, which takes its history too, so a new link never inherits it. Its taps lose their device data 30 days later, as for every [deleted link](#deleted-links-daily). The dashboard’s **Delete expired** button and `DELETE /api/admin/temp-links/expired` do the same at once for every expired link. See [Temporary links](/links/temporary-links/).
## Exchange rates (daily)
[Section titled “Exchange rates (daily)”](#exchange-rates-daily)
Downloads the ECB’s daily euro reference rates into `fx_rates` (`date`, `currency`, `rate`, plus `EUR` = 1), then fills `events.revenue_base` for up to 2,000 amounts that have none and a rate can convert: the amount in the account’s revenue currency, converted with the rate of the event’s day (the last rate published on or before it, else the first after it). A failed download is logged and retried the next day; amounts keep waiting with `revenue_base` null and count as 0 in totals. An amount in a currency the ECB does not publish waits the same way, without holding up the others.
## Anonymization sweep (daily)
[Section titled “Anonymization sweep (daily)”](#anonymization-sweep-daily)
The cutoff is `now minus RETENTION_DAYS` (default 400; `0`, blank or non-numeric also means 400). An account with a shorter window of its own (Settings → **Data retention**, 1 to 400 days; `PUT /api/admin/settings/retention`) is then swept again with its own cutoff, in the same order; a longer one has no effect.
First, for every account whose rollups are built through the cutoff’s day, the clicks, installs, events and app opens older than the cutoff are deleted, except events with a `transaction_id` (purchases, renewals, refunds), which stay and are nulled as below, so a restored purchase still counts once and a refund finds its purchase. `frozen_through` becomes the cutoff’s day: those days’ totals now live only in `link_daily` and are never rebuilt (a later change of account currency converts them with each day’s exchange rates instead). This is what keeps storage at about the retention window’s worth of rows; see [What it costs to run](/self-hosting/cost/).
Then `anonymiseExpiredDeviceData` runs one `db.batch`, so every statement commits or none does. Each account’s pass is on its own: one that fails is logged (`[dectog] retention: sweep failed`) and runs again the next day, and the others, and the purges in the table, still run. The sweep also runs when the day’s rollup build failed, since it deletes only the days the rollups hold. Rows of accounts whose rollups are not that far yet are kept for aggregate analytics with their personal columns set to `NULL`, as below.
### What is deleted, nulled and kept
[Section titled “What is deleted, nulled and kept”](#what-is-deleted-nulled-and-kept)
| Table | Action | Condition | Columns affected |
| --------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ad_identifiers` | DELETE rows | `created_at < cutoff` | whole row |
| `device_days` | DELETE rows | `day` before the cutoff’s day | whole row |
| `app_sessions` | DELETE rows | `started_at < cutoff` | whole row |
| `rejected_requests` | DELETE rows | `created_at` older than 30 days, whatever the retention setting | whole row |
| `events` | SET NULL | `created_at < cutoff` | `device_id`, `customer_id`, `metadata`, `ip_raw` |
| `customers` | SET NULL | `updated_at < cutoff` (last identify, not creation) | `name`, `email`, `phone`, `first_install_id` |
| `app_installs` | SET NULL | `created_at < cutoff` | `device_id`, `device_model`, `device_manufacturer`, `device_kind`, `cpu_type`, `os_build`, `screen_width`, `screen_height`, `screen_density`, `text_size`, `dark_mode`, `high_contrast`, `reduce_motion`, `utc_offset_min`, `connectivity_type`, `carrier_mcc`, `carrier_mnc`, `ip_raw`, `ip_hash`, `web_user_agent`, `meta_referrer`, `asa_token` |
| `app_opens` | SET NULL | `created_at < cutoff` | `device_id`, `install_id`, `click_id`, `ip_hash` |
| `clicks` | SET NULL | `clicked_at < cutoff` | `device_id`, `device_model`, `ip_raw`, `ip_hash`, `ua_raw`, `accept_language`, `region`, `city`, `asn`, `as_org`, `launch_referrer` |
| `clicks` (screen profile and device settings) | SET NULL | `clicked_at` older than `min(RETENTION_DAYS, 30)` days, only rows that still carry a value | `screen_width`, `screen_height`, `pixel_ratio`, `max_touch_points`, `text_size`, `dark_mode`, `high_contrast`, `reduce_motion`, `utc_offset_min` |
| `rate_limits` | DELETE rows | `window_start` older than the current 60 s window minus 2 | whole row |
| `erasures` | DELETE rows | `expires_at` passed: 8 days after a device or customer was erased | whole row |
| `webhook_deliveries` | DELETE rows | an account whose own window is under 30 days: older than its cutoff (otherwise the 30-day job below) | whole row |
The sweep also deletes what an erasure missed: for every tombstone still standing, an event of the erased device or customer stored in the minute after the erasure (the life of an edge location’s copy of the account, `SDK_AUTH_CACHE_SECONDS`) with a client time from before it; see [Privacy and data](/self-hosting/privacy-and-data/#a-request-from-one-person).
The screen profile and the device settings are dropped early because the fingerprint reads them only inside its window, which never exceeds 30 days (720 hours). `enriched_at` is kept on purpose: it is the “first beacon wins” gate, and a null would let a late beacon rewrite the row.
### Deleted links (daily)
[Section titled “Deleted links (daily)”](#deleted-links-daily)
`anonymiseDeletedLinks` gives the clicks and app opens of a link deleted more than 30 days ago (`links.deleted_at` or `temp_links.deleted_at`) the same treatment as above, whatever their age: the `clicks` personal columns, screen profile and device settings, and the `app_opens` columns, are set to `NULL`, only on rows that still carry a value. Thirty days is the cap of the attribution window, so an install from a tap made before the delete can still match its click until then. The link’s installs and events belong to your app’s users rather than to the link, and keep the `RETENTION_DAYS` schedule. A deleted link’s counts stay; a permanent delete removes the rows instead.
### What stays after the cutoff
[Section titled “What stays after the cutoff”](#what-stays-after-the-cutoff)
* **Clicks:** `id`, `user_id`, `username`, `link_slug`, `link_kind`, `source`, `device_type`, `os`, `os_major`, `browser`, `country`, `session_id`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `metadata`, `clicked_at`, `referrer` (origin only), `enriched_at`, `lang`, `os_minor`, `connection_type`, `bot_score`, `link_params`, `client_click_time`, `origin`.
* **Installs:** everything not listed above, including `install_referrer` (which contains `dtg_click=` and `dtg_l=/`), `metadata`, `asn`, `timezone`, `language`, `lang`, `os`, `os_version`, `os_major`, `os_minor`, `cpu_family`, `clipboard_slug`, `package_name`, `installer_package`, `is_physical_device`, the install and referrer timestamps, `fraud_flags`, the whole attribution verdict (`matched`, `matched_slug`, `matched_click_id`, `match_source`, `referrer_source`, `match_confidence`, `match_score`, `candidate_count`, `routing_click_id`, `fp_shadow_*`), `install_attempt_id`, `created_at_client`, `sent_at_client` and `asa_attribution`.
* **Events:** `type`, `event_name`, `revenue`, `currency`, `install_id`, `matched_slug`, `client_timestamp`, `created_at`.
* **Customers:** `customer_id` (the id your app chose), `created_at`, `updated_at`.
* **App opens:** `username`, `slug`, `app_version`, `os`, `created_at`.
### Tables the sweep never touches
[Section titled “Tables the sweep never touches”](#tables-the-sweep-never-touches)
`users`, `apps`, `links`, `temp_links` (the expiry delete only sets `deleted_at`), `audit_events` (kept as long as the account), `blog_categories`, `blog_posts`, `contact_messages` and `password_resets`. Contact-form messages and used or expired reset tokens (hashes only) therefore accumulate until someone deletes them: owners can delete messages in the dashboard; `password_resets` rows can only be removed with `wrangler d1 execute`.
### Limits
[Section titled “Limits”](#limits)
* An account can shorten its window, never lengthen it past `RETENTION_DAYS`.
* Past the cutoff, raw rows are deleted once their day is in the rollups, and only anonymized until then. Within the retention window, a tenant can delete a link permanently, or erase one device or customer (see [Privacy and data](/self-hosting/privacy-and-data/#a-request-from-one-person)); a plain delete keeps the rows.
* The sweep runs once a day at 03:17 UTC. Data older than the cutoff can exist for up to 24 hours before it is anonymized, and a Worker that is undeployed or erroring skips its runs silently.
### Checking that it ran
[Section titled “Checking that it ran”](#checking-that-it-ran)
D1 keeps no job history. Confirm with a query against the oldest rows, for example:
```bash
npx wrangler d1 execute dectog --remote --env production \
--command "SELECT COUNT(*) FROM clicks WHERE ip_raw IS NOT NULL AND clicked_at < strftime('%Y-%m-%dT%H:%M:%fZ','now','-400 days')"
```
A count of 0 means the last sweep covered everything older than the cutoff. To see how far the rollups are:
```bash
npx wrangler d1 execute dectog --remote --env production \
--command "SELECT user_id, through_day, frozen_through FROM rollup_state ORDER BY through_day LIMIT 20"
```
`test/retention.test.ts` and `test/rollups.test.ts` in the Worker repository pin the behavior described on this page.
# Concepts
> The objects DecTog works with, accounts, apps, links, clicks, installs, verdicts, claims, events and the link URL modes, and how they relate.
DecTog is small. Everything in the dashboard, the API and the SDK maps onto the handful of objects below.
## Account
[Section titled “Account”](#account)
An account is one tenant, one username and one link subdomain. The username is the first label of every link URL (`https://acme.dectog.com/welcome` belongs to the account `acme`) and the value the SDK and the verdict call `username`. It cannot be renamed, and an account cannot be deleted from the dashboard.
Each account has an account key (`dtg_` and 32 hex characters) for servers and scripts, with every scope, and one or more SDK keys (`dtg_pub_` and 32 hex characters) for the apps it ships, which cannot change or delete links unless created with the links:write option. A client authenticates with a key as a Bearer token and signs every request with it. A third kind, the site key (`dtg_site_` and 32 hex characters), sits in your web pages for [`dectog.js`](/sdk/web/) and works only from the origins it lists. All three kinds are managed under **Settings**. See [Authentication](/api/overview/#authentication).
Everything an account stores is scoped to it. There is no cross-account view. Several people can work in one account, each with a team role: owner, member or viewer. Beyond the account, the only privilege is the platform owner role, held by the deployment’s bootstrap account, which unlocks the Blog and Messages screens. See [Account and sign-in](/dashboard/account-and-sign-in/).
## Apps and the default app
[Section titled “Apps and the default app”](#apps-and-the-default-app)
An app is a registered iOS and/or Android application: its identifiers, store URLs, custom schemes, social preview, webhooks and attribution options. An account can register any number of apps on the **Apps** page.
The **default app** is the oldest app of the account, the first one you registered. It matters in these places:
| Situation | What the default app does |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A permanent link created without an app, or whose app was deleted | The link routes through the default app’s store URLs, schemes and fallback URL. |
| Every temporary link | Temporary links have no app of their own and always use the default app. |
| Strict fraud mode, the low-entropy attribution window and the webhooks of an app | Only as the last resort. These are read from the app the install concerns: the app of the credited or routed link; else the install’s own app, the one whose Android package or iOS bundle ID equals the `package_name` the SDK sends (on an account with one app, that app); else the oldest app. |
| Deleting an app | Its links are re-pointed to the default app. Clicks and installs are untouched. |
The well-known files are account-wide: `apple-app-site-association` lists every app with a bundle ID and Team ID, `assetlinks.json` every app with a package.
## Links
[Section titled “Links”](#links)
A link is a slug under your subdomain. Slugs contain letters, digits, `_` and `-`, are at most 128 characters long, and are unique across both link kinds of the account.
| | Permanent link | Temporary link |
| ----------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------- |
| Lifetime | Until you delete it, or until an expiry you set | Expires after a TTL of 60 seconds to 30 days (dashboard: 5 min, 1 hour, 1 day, 7 days) |
| Slug | Required | Optional; auto-generated as `t_` plus 10 hex characters |
| App | Chosen per link, else the default app | Always the default app |
| Target (**Fallback URL**) | Optional; `null` inherits the app’s fallback URL | Same |
| Deep-link paths, custom mobile redirect, custom desktop fallback, Tracking defaults | Yes | No |
| Social preview | Per link, else the app’s | Per link, else the app’s |
| Lifetime click counter | Yes | No |
A link’s target is a fallback, not a redirect for everyone: on a phone the store or the app comes first, and on a desktop the target only appears as a button on the QR page. [How a tap is routed](/links/how-a-tap-is-routed/) has the full order; [Temporary links](/links/temporary-links/) covers expiry.
## Link URLs and URL modes
[Section titled “Link URLs and URL modes”](#link-urls-and-url-modes)
The server builds link URLs in one of two modes, set by `LINK_URL_MODE` and `ROOT_DOMAIN`:
| Mode | URL | Used when |
| ----------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `subdomain` | `https://{username}.{ROOT_DOMAIN}/{slug}` | Hosted service (`ROOT_DOMAIN=dectog.com`) and production self-hosts with a wildcard DNS record |
| `path` | `{PUBLIC_BASE_URL}/r/{username}/{slug}` | Local development, `workers.dev` deployments. Forced whenever `ROOT_DOMAIN` starts with `localhost`, whatever the mode says. |
An account with an active [custom domain](/links/custom-domains/) gets `https://{custom domain}/{slug}` in either mode; its subdomain or path URLs keep working.
Three request forms reach the redirect engine on any deployment: `/{slug}` on a tenant subdomain or a custom domain (exactly one path segment), `/r/{username}/{slug}` on any host (on a custom domain only for its own account), and `/?user={username}/{slug}` on any host except the dashboard host and custom domains. The SDK recognizes all three when a link opens the app.
When an app is opened through a custom scheme, the URL is `scheme://{username}/{slug}`, or `scheme://{path}` when the link (or a `dec_deeplink` parameter) sets a deep-link path for that platform. Either way the query ends with `dtg_l={username}/{slug}`, which names the link: the SDK reads the link from that tag, because a path such as `products/42` cannot be told apart from a username and slug by its shape.
## Clicks
[Section titled “Clicks”](#clicks)
A click is one recorded tap on a link. Two sources exist:
| `source` | Recorded by | When |
| ------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `web` | The redirect engine | A browser tap that reached the server and was not a bot. Carries device type, OS, browser, client hints, geo and ASN from Cloudflare, the salted IP hash, the referring origin, `utm_*` and the `dec_*` tracking values. |
| `mobile_app` | The SDK, through `POST /api/v1/clicks` | A link opened the app directly (a verified Universal Link or App Link never touches the server), or an `intent://` bounce delivered the web click id to the app, in which case the existing web row is converted instead of inserting a second one. |
Bots and link unfurlers get a preview page and no click. Each click has a 16-character id; it rides on store URLs as `dtg_click=` and is what makes an install deterministic. A click can be credited to at most one install.
## Installs, verdicts and claims
[Section titled “Installs, verdicts and claims”](#installs-verdicts-and-claims)
An install is the first launch of the app on a device, registered once by the SDK with an idempotent `install_attempt_id`. The server answers with a verdict, the same object the SDK exposes as `DectogAppInstallResponse` and the dashboard as an install:
| Field | Meaning |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `matched` | Attribution credit. `true` only at `guaranteed` or `high` confidence. The dashboard counts these as attributed installs. |
| `type` | `non-organic` when matched, else `organic`. |
| `match_source` | `referrer` (Play or an OEM store), `clipboard` (copied link or paste), `fingerprint`, or `null`. |
| `referrer_source` | Which deterministic reader won: `google_play`, `huawei`, `samsung`, `xiaomi`, `vivo` or `meta`. |
| `match_confidence` | `guaranteed`, `high`, `medium`, `low` or `null`. |
| `routing` | A route the app may open even without credit: present whenever the install is matched, and at `medium` confidence with `matched: false`. |
| `username`, `slug`, `link`, `click_params`, `deep_link`, `clicked_at` | The matched link and the click’s `dec_*` values. `link` is only present for permanent links. |
| `is_reinstall`, `install_state` | Reinstall detection (same device seen before, or the SDK’s own classification) and the client’s `install` / `reinstall` / `update` classification. |
| `fraud_flags` | Rule hits such as `emulator` or `click_reuse`. |
| `apple_search_ads` | Always present; `null` until the SDK ever sent an AdServices token. |
A claim is a deterministic signal that arrives after the first launch: a Play referrer the store delivered late, a link pasted through `DectogPasteButton`, a late Meta read. The SDK sends it to `POST /api/v1/app-installs/{id}/claim`; the verdict changes at most once, from organic or fingerprint to deterministic, and the SDK re-emits it with `claim: 'upgraded'`. [How attribution works](/start/how-attribution-works/) explains the signals and the confidence levels.
## Events, customers and app opens
[Section titled “Events, customers and app opens”](#events-customers-and-app-opens)
After the install the SDK can report:
| Object | SDK call | What it is |
| -------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Event | `trackConversion`, `trackEvent`, `identify` | Typed rows (`conversion`, `custom`, `lead`, `identify`) stamped with the install’s matched link, so leads and revenue appear per link. `identify` is stored as an event too; the first identify of a new user on a fresh install is the `lead`. Batches of up to 100 events. |
| Customer | `identify(customerId: ...)` | Your user id with optional name, email and phone, bound to the device’s install attribution. A later call overwrites the fields it sends and keeps the ones it omits; the install association stays with the first install. Browse them under **Customers**. |
| App open | Automatic | A DecTog link opened the already-installed app. Counted as **Opens** on the Overview and per link. |
See [Events](/sdk/events/) and [Events and attribution](/api/events-and-attribution/).
## Webhooks
[Section titled “Webhooks”](#webhooks)
An account can have any number of webhooks, each taking one app’s events or every app’s. The server POSTs the events a webhook is subscribed to: `app.installed` for every first-launch verdict and `app.install_updated` when a claim upgrades it, conversions and leads, app opens, referrals, link changes and, opt-in, every tap. The body is the envelope `{event_id, event, created_at, attempt, data}`, signed with `x-dectog-signature: sha256=`. Every delivery is logged, and a failed one is retried for about a day. See [Webhooks](/api/webhooks/).
## Where things live
[Section titled “Where things live”](#where-things-live)
| Object | Dashboard | API | SDK |
| --------------------------------- | -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- |
| Account, keys, link configuration | Settings | `/api/admin/settings`, `/api/admin/keys` (cookie session) | `DectogConfig(apiKey:)` takes an SDK key |
| Apps | Apps | `/api/admin/apps` | |
| Links, temporary links | Links | `/api/v1/links`, `/api/v1/temp-links`, `/api/v1/resolve/{username}/{slug}` | `createLink`, `createTempLink`, `resolveLink` |
| Clicks | Overview, per-link numbers | `/api/v1/clicks` | Automatic |
| Installs, verdicts, claims | Overview, Customers | `/api/v1/app-installs`, `/api/v1/attribution` | `onAppInstalled`, `getAttributionData()` |
| Events, customers, opens | Overview, Links, Customers | `/api/v1/events`, `/api/v1/app-opens` | `identify`, `trackConversion`, `trackEvent` |
The `/api/v1` routes take a key (account or SDK) and the request signature; the `/api/admin` routes take the dashboard’s session cookie. See [REST API overview](/api/overview/).
# Design decisions
> Why DecTog works the way it does, the designs it rejected and why, and the questions still under review.
Why DecTog works the way it does, including the designs it chose not to adopt. Each entry states the decision, the reason and what was rejected. The limits these decisions produce are listed in [What DecTog does not do](/start/what-dectog-does-not-do/).
## Kept as they are
[Section titled “Kept as they are”](#kept-as-they-are)
**1. Reinstalls go through matching.** A reinstall runs the attribution ladder like a first install and is credited when a signal matches; it carries `is_reinstall: true` and is excluded from the Installs KPI and per-link counts. Only `install_state: 'update'` and a reused click skip the fingerprint. *Rejected:* skipping matching for reinstalls, which would lose re-acquisition campaigns. *Revisit* if re-attribution windows become a feature. (2026-10-13) *Note (2026-10-15):* the fingerprint is also skipped for an install without an IP hash, a registration that carries `recovered_from`, and an app whose **Probabilistic match** switch is off.
**2. v1 DELETE answers 404 for an unknown slug; the admin DELETE answers 200.** Different clients: an SDK or script wants to know the slug was wrong; the dashboard deletes what it lists. (2026-10-13)
**3. Fingerprint candidates are every mobile web click,** not only clicks that were sent to a store. *Rejected for now:* a `destination` column on clicks (store, custom, bounce, tail) and a filter on it; the effect is mild and the migration touches three docs pages. (2026-10-13)
## Settled for credibility (2026-10-14)
[Section titled “Settled for credibility (2026-10-14)”](#settled-for-credibility-2026-10-14)
**4. No backward compatibility before the first public release.** Nobody integrates DecTog yet, so response shapes, SDK methods and the schema change freely. *Rejected:* shims, v2 routes and deprecation periods before there is a user to protect. Ends at the first public release.
**5. One error envelope.** Every route answers `{"error": {"code", "message", "request_id"}}`; `code` is stable, `message` may change, and `request_id` (also `x-request-id`) finds the log line. The id is a well-formed inbound `x-request-id`, else Cloudflare’s `cf-ray`, else a UUID. *Rejected:* keeping a bare `{"error": ""}` string, which clients had to parse by text.
**6. HEAD is a bot; QA taps are not clicks.** A HEAD to a link answers the social preview page and records nothing; `x-dectog-no-track: 1` or `?dtg_nt=1` routes like a real tap without a row. *Rejected:* counting link checkers and uptime monitors as visitors.
**7. Webhook secrets are write-only.** The apps list returns a hint (the last four characters); a generate/rotate route returns a new `whsec_` secret once. *Rejected:* returning the secret on every GET and showing it in the form. *Note (2026-10-15):* since webhooks moved to their own table, the webhooks list returns no secret and no hint; creating a webhook and `POST /api/admin/webhooks/:id/rotate-secret` return the secret once.
**8. Sign-in is limited per IP, never per account.** 10 attempts a minute per hashed IP; reset requests 5 per IP and 3 per address. *Rejected:* a per-account lockout, which lets anyone lock a user out by failing on purpose.
**9. `setEnabled` replaces `optOut`.** One persisted switch, and `DectogConfig(enabled: false)` to start off until consent. While off the SDK makes no request of its own and reads no pasteboard; it holds events on the device and the launch link’s report in memory, and turning it on sends them and registers an install that a disabled launch skipped. A revoke drops the held link reports, not the held events. *Rejected:* keeping `optOut`, which never stopped clicks or the install, next to a second consent API.
**10. Scheme links name their link in `dtg_l`.** Every custom-scheme URI the server builds ends with `dtg_l=/`, and the SDK reads the link from it. *Rejected:* reading username and slug from the URI’s shape, which turned an app route like `myapp://products/42` into user `products`, slug `42`.
**11. The webhook hides link fields unless the install is credited,** exactly like the SDK verdict. *Rejected:* sending `slug` next to `matched: false` on a strict-mode install, which invited consumers to credit it.
**12. Raw IP addresses are opt-in.** Matching, fraud rules and rate limits use only the salted hash; `STORE_RAW_IP=1` also keeps the raw address for the Customers page. *Rejected:* storing raw IPs by default for a debugging view. *Note (2026-10-19):* reversed by the owner. Raw IPs are now stored by default, so the Customers page shows the install and click IP on every deployment, and `STORE_RAW_IP=0` turns that off. Matching, fraud rules and rate limits still use only the salted hash, and the retention sweep still clears the raw address.
**13. `healthz` and `readyz` are reserved slugs,** because the Worker answers those paths on every host.
**14. No percentage from a zero baseline.** An Overview card whose previous window was 0 shows no delta. *Rejected:* reporting +100%.
## Settled for deploying and integrating
[Section titled “Settled for deploying and integrating”](#settled-for-deploying-and-integrating)
**15. Two kinds of key; the account key stays on `users` (2026-10-14).** Apps ship SDK keys (`dtg_pub_…`, table `api_keys`) that cannot change links unless created with `links:write`; servers use the account key (`dtg_…`), which keeps every scope. *Rejected:* moving the account key into `api_keys`, which needs a rebuild of the `users` table (its hash column is `UNIQUE`) on a production database for no user-visible gain; and one key for everything, which put a link-deleting credential in every app binary. *Note (2026-10-15):* there are now three kinds: site keys (`dtg_site_…`) are `api_keys` rows of kind `site` that web pages carry for `dectog.js`, limited to the two web routes and the origins each key lists.
**16. One contract table generates the spec and the route index.** Every `/api` route is declared once in `src/contracts.ts`; `/openapi.json` and the route list in the server repository’s `docs/API_CONTRACT.md` are generated from it, and a test fails when a route and the table disagree. *Rejected:* zod schemas (a new runtime dependency for the Worker) and a hand-written spec (it drifts).
## Settled for attribution correctness
[Section titled “Settled for attribution correctness”](#settled-for-attribution-correctness)
**17. Repeat taps from one phone are one fingerprint candidate.** Clicks from one network whose model or screen profile, OS major, language and CPU family all agree collapse into the newest before ranking, so a double tap no longer ties with itself. The cost: a household with identical phones on one network credits the newer tap instead of nobody. Clicks with neither a model nor a screen profile never collapse; agreeing on OS and language alone is not evidence of one device. *Rejected:* keeping the tie (the common double tap earned no credit and no routing), and reusing one click id per device at redirect time (a database read on every redirect for a gain the match-time collapse already gives). *Note (2026-10-17):* CPU family left the signature together with the CPU signal (decision 30). *Note (2026-10-23):* tied taps on one link now merge too, whatever their signals, when they carry the same click params, UTM tags, referrer, routing rule, browser and place: either one gives the install the same verdict. A Safari double tap carries no model or screen profile, so it never collapsed and ended organic. The cost: two people on one network who tapped the same link and both install may each hold the other’s click row.
**18. A same-device reinstall is routed, not credited.** When nothing else matches, a reinstall on a device whose earlier install consumed a click gets that click back as routing inside the deterministic window (7 days by default). The credit stays with the first install, so one tap is never counted twice. *Rejected:* crediting the reinstall (double counting) and leaving it organic (the user who reinstalls right after a tap lands nowhere). Still owed: a device run that confirms the iOS device id survives the reinstall.
## Settled for links and analytics
[Section titled “Settled for links and analytics”](#settled-for-links-and-analytics)
**19. Webhook retries run on D1 and the hourly cron.** A failed delivery waits in `webhook_deliveries` for the hourly job, which sends at most 300 retries a run, so the 5- and 30-minute retries land at the next run. *Rejected:* Cloudflare Queues, which retries within the minute but is a second service to provision on every deployment. *Revisit* if a receiver’s backlog outgrows 300 an hour.
**20. Retention counts calendar days in UTC, and day N means exactly day N,** as Adjust and AppsFlyer report it; the weekly table counts any activity inside each week. *Rejected:* 24-hour buckets counted from the install time (an install at 23:00 and an open the next evening share a bucket) and each device’s local day, which needs the device’s time zone joined into every activity row.
**21. A deleted link keeps its history under its slug.** Rollups are rebuilt from the raw rows for closed days, never incremented, and a deleted link’s numbers stay filed under its slug; creating that slug again answers 409 with `"deleted": true` until the link is restored or deleted permanently. *Rejected:* decrementing the rollups on delete (past totals would change) and incrementing them on every write (a written row per tap, and drift whenever a claim, refund or exchange rate changes a row later).
**22. The web pixel keeps a first-party cookie and asks for consent where the law does.** `dectog.js` stores its click id in a 30-day first-party cookie, waits for `dectog.consent(true)` in the EU and EEA, the UK and Switzerland, treats Global Privacy Control as a no, and uses its own key kind (`dtg_site_`), one per account. *Rejected:* `sessionStorage`, which loses the click when the visitor comes back in a new tab, and consent in the EU only. *Note (2026-10-15):* an account can hold up to 10 active site keys, each with its own list of origins.
**23. An in-app `handleUrl` open counts as an open, not a click.** The link was already tapped inside the app, so no click row is written and the Clicks KPI keeps meaning taps on the web.
## Settled for trust
[Section titled “Settled for trust”](#settled-for-trust)
**24. The audit log is append-only and numbered, without a hash chain.** Entries are numbered per account, so a deleted entry shows as a gap in **Check for gaps**, and kept as long as the account. *Rejected:* a hash chain with a verify route: whoever can rewrite the database can recompute the chain too, so without an outside anchor it adds a column, not a guarantee.
**25. A test environment is a sibling account.** Account `acme` gets `t-acme`, with copies of its apps and templates and its own keys, links, installs, events, webhooks and audit log. *Rejected:* an `is_test` flag on every row, which needed a filter in every query and could leak test data into live totals.
## Settled for the ecosystem and ad networks (2026-10-15)
[Section titled “Settled for the ecosystem and ad networks (2026-10-15)”](#settled-for-the-ecosystem-and-ad-networks-2026-10-15)
**26. Only `dec_*` parameters survive a deferred install.** A browser tap stores its `dec_*` and `utm_*` values and nothing else, so those are all the install verdict can return (`click_params`). On a warm open the app reads the opened URL itself (`click.queryParameters`, from `originalUrl`); a route that must also survive the trip through the store goes in `dec_deeplink` (`products/42`), which works on both paths. *Rejected:* keeping the tapped URL’s other parameters on the click to return them after the install: the click would store whatever a visitor’s URL holds, personal data included, and an app would build on parameters that only some paths deliver.
**27. No ad-network click route.** Ad networks cannot send DecTog their clicks server to server, and the advertising id is stored but never matched on; an ad’s install is attributed when its tap went through a DecTog link. *Rejected for now:* an `s2s/click` route with per-network tokens and an advertising-id rung in the ladder. It suits networks that do not attribute themselves (AppLovin, Unity, ironSource and similar) and needs the advertising id consented on the device; *revisit* when a user asks for one of them.
**28. Screen views are their own event type, outside every event total.** A `screen_view` names the screen in `event_name`, carries no revenue, and is left out of re-open counts, rollups, the event counts and lists of customers and installs, and the events list; it stays in exports, erasure and retention activity. It has read-outs of its own: per link, the screens reached within 30 minutes of the link opening the app; per customer and per install, the app sessions with the screens shown in each, whole sessions, so a customer’s screens before sign-in show too. *Rejected:* a `custom` event named `screen_view`, the first sketch, which would have counted every screen in the totals the dashboard sells; and a table of its own, a second copy of the queue, retention and erasure paths for one column of difference. *2026-10-18:* the type and both read-outs stay; the storage changed, see 32: one row per session instead of an event row, and eight index rows, per screen.
**29. Each SDK keeps its logic in its own language; native code is shared as source.** `flutter_dectog` (Dart) and `react-native-dectog` (TypeScript) each implement the attribution logic and sit on thin native layers, and the native reads (store referrers, pasteboard, paste control, Keychain id, install dates) live once, in framework-free Kotlin and Swift files both bridges call; the React Native repository copies them and its CI fails on a difference. One fixture set (the server’s wire responses, the URL cases) and one harness per SDK keep the implementations equal. The React Native port took about 4,000 lines of TypeScript and a 400-line bridge, with no native code of its own. Native iOS and Android SDKs, when built, are ports on the same terms: Swift and Kotlin logic held to the same fixtures, calling the same shared files directly. *Rejected:* standalone native SDKs (`DecTogKit`, `dectog-android`) that the Flutter and React Native packages wrap, Grovs’ shape: every fix would wait for a native release and a wrapper release (R19), the Dart and TypeScript tests would only test bridges, and two of the four implementations would be thrown away.
## Settled for attribution signals (2026-10-17)
[Section titled “Settled for attribution signals (2026-10-17)”](#settled-for-attribution-signals-2026-10-17)
**30. The fingerprint has no CPU signal.** No phone browser tells the click its CPU: iOS Safari sends no client hints, and Android Chrome answers `Sec-CH-UA-Arch` with an empty string (both measured on a simulator and an emulator). The +5 / −40 check only ever fired on test input, so it is gone, and the redirect no longer asks for the hint. The install’s `cpu_type` stays for the emulator fraud flag. *Rejected:* reading the architecture from the user agent, `navigator.platform`, WebGL or floating-point quirks: every phone is ARM, so the answer is constant and tells candidates nothing apart.
**31. Device settings are recorded on both sides, unscored until calibrated.** Text size, dark mode, Increase Contrast, Reduce Motion and the UTC offset are read by the page beacon and by the SDK (inside the device tier, on by default), and the iOS minor version comes from Safari’s `Version/` token. Each is compared on its own and stored as an `unscored` verdict worth 0 points; on installs that a deterministic signal decided, the verdicts of the click known to be right go into `signal_check` and the attribution-quality panel, and a signal earns points only once those numbers support it. The reason: two phones of the same model behind one carrier network score the same today, so when their taps lead to different links nobody is credited. The cost: Apple’s rules forbid deriving data from a device’s configuration to identify it, and accessibility settings can suggest a disability, which the EU may treat as health data. An app that cannot carry that leaves the device tier out or turns the probabilistic match off, which also stops its link pages reading the settings. *Rejected:* hashing settings into one identifier (R1); canvas, WebGL, audio and installed-font probes (Safari randomises them or they are the same on every iPhone of one iOS version, and the app has nothing to compare them with); scoring the settings before any calibration.
## Settled for the cost of an app user (2026-10-18)
[Section titled “Settled for the cost of an app user (2026-10-18)”](#settled-for-the-cost-of-an-app-user-2026-10-18)
**32. A session’s screens are one row, written once per flush.** The flush keeps each app session’s screen views on an `app_sessions` row: the device, install, customer and link open the session is first known by, the device time of its first screen, the count, and the first fifty screens as a JSON list in the order shown. The first flush inserts the row, four written rows with its indexes (six when the customer and the open are known by then); every later flush appends to the list and the count, one written row, where each screen view cost nine as an event. A sign-in in a session whose screens came before it makes the session the customer’s, so the screens before sign-in show with the rest; a session keeps the open that started it, so a link opened while the app already runs is not a second open. Both read-outs read those rows: the per-link screens after an open, keyed by the device and the open time, and the sessions of an install or a customer. A screen view without a session id is accepted and not stored. *Rejected:* screen views as events behind narrower indexes, still six written rows per screen; the screens on the open’s own row (`app_opens`, `app_installs`), which had no place for the sessions a customer or an install lists; an open per screen inside the list, which no index could serve.
**33. An app start is one row per device and day.** `POST /api/v1/device-ping` writes a `device_days` row for the device’s first start of the UTC day and nothing for the rest; the SDKs ping once a day. The retention read-out counts device days, which is all a ping ever fed. *Rejected:* one row per app start (three written rows each, at every start of every device), a timeline nothing read.
**34. An SDK key and its account come from one read, kept per isolate for a minute.** Authentication joins `api_keys` to `users` in one statement, and an isolate keeps the pair for `SDK_AUTH_CACHE_SECONDS` (default 60), so an SDK call spends its round trips on its own data: a ping is one statement instead of three. The isolate that revokes a key or erases a subject forgets the account at once; the others keep their copy until it expires, so a revoked key may work, and an erasure may go unseen, for up to a minute. The daily job deletes the events that slipped past an erasure in that minute. *Rejected:* a shared cache (KV, a Durable Object), a round trip of its own; no cache, which held the measured D1 ceiling at about 130 pings a second.
## Rejected designs
[Section titled “Rejected designs”](#rejected-designs)
Each of these appears in at least one competing project.
| # | Rejected | Why |
| --- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| R1 | Raw IPs or user agents as matching keys, browser fingerprinting hashes, hardware identifiers | Apple’s rules prohibit fingerprinting and the EU audience cannot ship it; salted hashes and a scored, deterministic-first ladder instead. *Note (2026-10-17):* the ladder now also compares a few device settings one by one (decision 31); nothing is combined into an identifier |
| R2 | Persistent cookies or third-party tags on link pages | Redirects are cookie-free and stay so |
| R3 | The client choosing windows, install state or verdicts | Windows, state and verdicts are decided server-side |
| R4 | “Bring your own auth” and unauthenticated install or event routes | Every write is authenticated and signed; a future ad-network route gets its own token and rate bucket |
| R5 | An `ee/` paywall or plan gating in the engine | The whole product is AGPL; limits are deployment config, never data model |
| R6 | A multi-service analytics stack (queues, ClickHouse, Tinybird) | Rollup tables in D1 give the read speed with one service |
| R7 | Reporting an install on every launch, or “organic” on a network failure | A sentinel, an `install_attempt_id` and retry on the next launch |
| R8 | Targeting rules that 404 on non-match | Geo, language, schedule and A/B rules route a visitor; they never gate a link |
| R9 | Opening `myapp://` automatically on page load | iOS shows “Cannot open page” to everyone without the app; schemes open from a tap only |
| R10 | Push messaging, affiliate programs, domain sales, LLM link scanning | Outside the product’s scope; each adds credentials and moving parts |
| R11 | Row-per-impression storage and recomputed attribution views | D1’s size cap and read billing; verdicts are computed at write time |
| R12 | Documenting ahead of code | Docs state what the code does |
| R13 | A CLA on the AGPL server | Inbound = outbound is the simpler story (still an open question) |
| R14 | Silent dedupe of events | Batch answers say what was accepted, duplicated and rejected |
| R15 | Fingerprint first, deterministic signal as the fallback | A deterministic signal never waits behind a guess |
| R16 | Holding the app’s own events until attribution resolves | Events are independent of the verdict; the server stamps `matched_slug` |
| R17 | Requiring apps to turn off Flutter’s own deep linking | The `app_links` stream coexists with `go_router` and Navigator 2.0 |
| R18 | Refusing to run without a URL scheme, or a privacy default baked into build metadata | Warn, never refuse; `initialize()` and `setEnabled` are the control points |
| R19 | Wrapping an opaque published native SDK | Shared native code stays source-level and testable in one layer |
## Under review
[Section titled “Under review”](#under-review)
**IDFV kept across reinstalls.** With `persistDeviceId: true` (the default) the iOS SDK stores the vendor-scoped IDFV in the Keychain, so the device id survives a reinstall. It is vendor-scoped and never leaves the account, but rejected design R1 lists reinstall-surviving Keychain ids among the patterns to avoid. Decide whether the default stays on, and say so in the privacy docs either way.
# How attribution works
> The signals that connect an install to a link tap, in order, what each confidence level means, the matching windows, reinstalls, fraud flags and Apple Search Ads.
Install attribution answers one question: which link tap, if any, led to this first launch? DecTog answers it with a ladder of signals. Deterministic signals, which carry the click id itself, come first. A scored fingerprint comes last, and it only credits an install when it is sure. This page describes the behavior in user terms; the scoring details are in [Attribution internals](/self-hosting/attribution-internals/).
## What happens at the tap
[Section titled “What happens at the tap”](#what-happens-at-the-tap)
A tap on `https://acme.dectog.com/welcome` records a click with a 16-character id and sends the visitor on. Where the click id goes next depends on the platform:
| Platform | Carrier of the click id |
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Android, to Google Play or an OEM store | The store URL’s `referrer` parameter: `dtg_click=&dtg_l=acme/welcome`. The store hands the string back to the app after the install. |
| iOS, with **iOS tap-to-store** on | The “Get the app” tap copies `https://acme.dectog.com/welcome?dtg_click=` to the pasteboard, then opens the App Store. |
| iOS, without tap-to-store | Nothing. Apple’s `ct` tag is set on the store URL for App Store Connect, but it never reaches DecTog. |
| Any mobile tap, through the interstitial or tap-to-store page | The page also beacons the screen profile onto the click, which sharpens the fingerprint, and a few device settings, which are recorded for no points (see [Signals recorded for no points](#signals-recorded-for-no-points)). |
If the app is already installed and the OS opens it directly, there is no install to attribute; the SDK reports an app open instead.
## What the SDK gathers on first launch
[Section titled “What the SDK gathers on first launch”](#what-the-sdk-gathers-on-first-launch)
On the first launch the SDK registers the install once (an idempotent `install_attempt_id` makes retries safe) with every deterministic signal it can read:
* the Google Play install referrer, or the Huawei, Samsung, Xiaomi or vivo one, waiting at most 5 seconds (`installReferrerTimeout`);
* the pasteboard, on iOS by default (`DectogCollection.pasteboard`), only when it holds a URL and only if that URL is a DecTog link for this deployment;
* Meta’s on-device install referrer on Android, when the app has a Facebook App ID (`DectogConfig.fbAppId`, else the one saved on the Apps page);
* the device tier (model, OS, language, screen, text size and Increase Contrast (iOS only), dark mode, Reduce Motion, connectivity, carrier), on by default, which the fingerprint needs;
* the Apple Search Ads token when `collect` names `appleSearchAdsToken`.
A signal that arrives later, in the same process or on a later launch, is sent as a claim. See [Late claims](#late-claims) below.
## The ladder
[Section titled “The ladder”](#the-ladder)
The server walks the signals in this order and stops at the first one that names a click nobody else has used:
| Step | Signal | `match_source` | `match_confidence` | Result |
| ---- | ------------------------------------------------------------------------------------- | ------------------------------------ | ------------------ | -------------------- |
| 1 | Install referrer from Google Play or an OEM store (`referrer_source` names the store) | `referrer` | `guaranteed` | Credited |
| 2 | Copied link or paste: the pasteboard URL carries `dtg_click` | `clipboard` | `guaranteed` | Credited |
| 3 | Meta on-device referrer | `referrer` (`referrer_source: meta`) | `guaranteed` | Credited |
| 4 | Fingerprint over recent mobile web clicks from the same hashed IP and device family | `fingerprint` | `high` | Credited |
| 4 | Same, with a unique but weak best candidate | `null` | `medium` | Routed, not credited |
| 4 | Same, with a tie between different links or a contradiction | `null` | `low` | Nothing |
| | No signal and no candidate | `null` | `null` | Organic |
Two rules hold throughout:
* **One install per click.** A click credited to another device’s install is skipped, and the ladder moves on to the next signal. Only when nothing later matches does the install end up organic with the fraud flag `click_reuse`. A click credited to an earlier install of the *same* device returns that install’s verdict instead of creating a new one.
* **A referrer is proof of the tap.** If the click row never existed (a tap whose click was lost, see [when the click is recorded](/links/how-a-tap-is-routed/)), the server creates it from the `dtg_l=username/slug` part of the referrer and still credits the install deterministically.
When a deterministic signal decides, the fingerprint still runs in the background and records whether it would have picked the same click. That shadow match is the “Fingerprint accuracy” figure on the Overview.
## Confidence levels
[Section titled “Confidence levels”](#confidence-levels)
The dashboard’s **Match confidence & referrer health** panel explains each level with a tooltip. The meanings are exactly these:
| Level | Tooltip | `matched` | `routing` |
| ------------ | ----------------------------------------------- | --------- | ---------------------------------- |
| Guaranteed | install referrer, copied link or paste | `true` | present |
| High | fingerprint, corroborated and unique — credited | `true` | present |
| Medium | routed to the link, not credited | `false` | present, with `confidence: medium` |
| Low | ambiguous or contradicted — nothing | `false` | absent |
| No candidate | no click from this network in the window | `false` | absent |
In fingerprint terms: *unique* means the best candidate leads the runner-up by at least 10 points or is the only one; *corroborated* means a device-model match, a screen-profile agreement together with an OS-major agreement, or any score of 15 or more; *medium* is a unique best candidate scoring 0 to 14, for example an OS-major agreement cancelled by a language mismatch; *low* is a tie between candidates on different links (or with different click params), or a negative score. Repeat taps from one phone (identical signals) count as one candidate, and so do tied taps on one link that would give the install the same verdict, so neither ties with itself. A device-model mismatch excludes a candidate outright.
Use `matched` for credit and `routing` for navigation. Routing a new user to the content they tapped is cheap to get wrong; crediting a campaign is not.
## Signals recorded for no points
[Section titled “Signals recorded for no points”](#signals-recorded-for-no-points)
The fingerprint also compares eight values that score 0 points, so none of them changes a verdict: the iOS minor version (compared only when the OS major versions match), text size, dark mode, Increase Contrast, Reduce Motion, the UTC offset, the language with its region (compared only when both sides carry a region) and, for a tap from an Android in-app browser, the WebView build. The tap’s values come from the interstitial and tap-to-store pages, from Safari’s user agent for the iOS version, from the `Accept-Language` header for the region and from the in-app browser’s user agent for the WebView build; the install’s from the SDK. Each candidate in the install’s explanation marks them agree (exactly equal), mismatch or missing. On an install a deterministic signal matched, the same comparison against the click that signal named is kept as `signal_check`, and the attribution-quality read-out and the Overview’s **Signals on known matches** table count it, to measure how often each value agrees on the right click. A late claim that upgrades the verdict records one too. `signal_check` also says whether that tap came from the install’s address at all and, if not, whether one side used IPv4 and the other IPv6: the fingerprint only ever sees taps from the install’s address, so this measures how many installs it could not have matched. None is kept when the click was rebuilt from the referrer tag, which has no browser side, or when the app’s **Probabilistic match** is off.
Two phones of the same model behind one carrier network score the same today, so when their taps lead to different links nobody is credited; these values are recorded to find out whether they tell such phones apart. They carry privacy costs of their own, described on [Device data and privacy](/sdk/device-data-and-privacy/#device-settings). Why they score nothing yet: [Design decisions](/start/design-decisions/).
## Matching windows
[Section titled “Matching windows”](#matching-windows)
Only mobile web clicks of your account, from the same hashed IP (IPv6 at /64) and the same device family, that no install has claimed yet, are candidates. How far back the server looks depends on how much the click and the install carry:
| Case | Window |
| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The click carries a device model, the install carries one too, and the install is not on cellular data | 24 hours (`ATTRIBUTION_WINDOW_HOURS`) |
| Everything else: a click without a model (iOS Safari sends none), or an install on cellular data | 2 hours (`ATTRIBUTION_LOW_ENTROPY_WINDOW_HOURS`), or the app’s **Low-entropy attribution window** (1 to 720 hours), never longer than the 24-hour window |
The per-app window is the install’s own app’s: the app whose package or bundle ID the SDK reports, or the only app of a one-app account. An install is also only matched against clicks on its own app’s links, and on links bound to no app. A store referrer has no window: one older than 30 days still matches, with the fraud flag `stale_referrer`. An app can turn the probabilistic match off (Apps, Advanced, **Probabilistic match**); its installs then use the deterministic signals only. A pasted link and Meta’s referrer count for 7 days (the app’s **Pasted-link and Meta window**, 1 to 720 hours). An older one opens the link’s content in the app (routing) but earns no credit and carries `stale_clipboard` or `stale_meta`; with strict fraud mode on it does nothing.
## Reinstalls and app updates
[Section titled “Reinstalls and app updates”](#reinstalls-and-app-updates)
| Case | How it is detected | What happens |
| ---------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| App update | The SDK sees that the app was installed before this build (`install_state: update`) | Registered, never fingerprint-matched. The Overview’s quality panel counts these as “kept out of matching”. A deterministic signal still applies. |
| Reinstall | The server has seen the device id before, or the SDK classifies it from the iOS Keychain dates (`install_state: reinstall`) | Registered with `is_reinstall: true`. It still runs the ladder, but the Installs KPI and the per-link install counts exclude it. The quality panel includes it. When nothing else matches, it is routed (not credited) to the link its earlier install on the same device came from, if that tap is within the pasted-link window (7 days by default), so the user lands on the same content again. |
The device id survives a reinstall by default (`ANDROID_ID` on Android, the first IDFV kept in the Keychain on iOS with `persistDeviceId`). Repeated test installs on one device therefore show as reinstalls.
## Late claims
[Section titled “Late claims”](#late-claims)
The verdict can change once, from organic or fingerprint to deterministic:
* The SDK re-reads a non-definitive Play referrer on up to five later launches and sends it as a claim when it arrives.
* A link pasted through `DectogPasteButton` (iOS 16 and later, no system alert) is sent as a claim.
* A late Meta read is sent as a claim.
Claims never run the fingerprint and never downgrade: a deterministic verdict is final, including a strict-mode verdict that was zeroed. The response carries `claim: upgraded | unchanged | rejected` with a `claim_reason` (`click_reuse`, `click_not_found`, `no_signal`, `already_deterministic`, `stale_click`), and the SDK re-emits an upgraded verdict on `onAppInstalled`.
## Fraud flags and strict mode
[Section titled “Fraud flags and strict mode”](#fraud-flags-and-strict-mode)
Every install is checked against explainable rules. The hits ride on the verdict as `fraud_flags`, on the webhook and in the dashboard:
| Flag | Fires when |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `emulator` | The SDK reports a non-physical device, the model name looks like an emulator, or an Android phone reports an x86 CPU |
| `impossible_speed` | The deterministic click happened less than 10 seconds before the install report |
| `install_velocity_ip` | The same hashed IP already produced 5 installs in 24 hours (25 on cellular data) |
| `click_storm_ip` | The matched click’s IP produced more than 100 clicks in the hour before it |
| `stale_referrer` | The referrer or clipboard click is older than 30 days |
| `stale_clipboard` | A pasted link’s click is older than the pasted-link window (7 days by default); the install is routed, not credited |
| `stale_meta` | The same for Meta’s install referrer |
| `click_reuse` | A deterministic signal named a click another device’s install already consumed |
With **Strict fraud mode** off (the default) attribution stands and the flags are evidence. With it on, a flagged install is stored with `matched: false` and `match_source: null` and counts as organic; the record keeps `referrer_source`, `match_confidence` and the matched slug (the app’s verdict and the `app.installed` webhook show the first two but never the slug), and the click stays consumed so nobody else can claim it. Strict mode is read from the app the verdict concerns: the credited or routed link’s app, else the install’s own app, else the oldest app.
## Apple Search Ads
[Section titled “Apple Search Ads”](#apple-search-ads)
Apple Search Ads is a separate signal and never changes `matched`. When `collect` names `appleSearchAdsToken`, the SDK sends Apple’s AdServices token with the install (or with a later claim). The server exchanges it with Apple: three attempts 5 seconds apart during the request, then one attempt per cron run, until the token’s 24-hour lifetime ends. The verdict’s `apple_search_ads` object reports the status:
| Status | Meaning |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending` | Waiting for Apple |
| `resolved` | Apple matched the install to a campaign; the record (campaign, ad group, keyword and ad ids, tap-through or view-through) is included and the webhook fires again |
| `no_attribution` | Apple found no record |
| `invalid` | Apple rejected the token |
| `expired` | 24 hours passed without an answer |
| `failed` | No token was present when the lookup ran |
The field is always present on the verdict and is `null` until a token was ever sent. The Overview’s Apple Search Ads card appears once the window contains a result.
## Where you see the verdict
[Section titled “Where you see the verdict”](#where-you-see-the-verdict)
* In the app: `onAppInstalled` on first launch and on upgrades; `getAttributionData()` later, re-pulled from the server when the cache is empty.
* On your server: the `app.installed` webhook.
* In the dashboard: the Installs and Conversion cards, the **Attribution quality** card, the **Match confidence & referrer health** panel (confidence per platform, Android referrer coverage, fingerprint accuracy, click-to-install latency) and each customer’s attribution block.
# Quickstart
> Create an account, register your app, add the Flutter SDK, create a link and see the first attributed install in the dashboard.
This page uses the hosted service: the dashboard and API live at `https://app.dectog.com`, links at `https://{username}.dectog.com/{slug}`. Self-hosting changes a few values; the steps call them out. The examples use the username `acme` and the slug `welcome`.
1. ## Sign up
[Section titled “Sign up”](#sign-up)
Open [app.dectog.com/login?tab=signup](https://app.dectog.com/login?tab=signup). The page opens on the **Sign up** tab.
| Field | Rule |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Email | A valid address. It is lowercased and becomes your sign-in name. |
| Username | 3 to 32 characters: lowercase letters, digits and hyphens, starting and ending with a letter or digit (`^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$`). Reserved names: `app`, `www`, `api`, `links`, `dashboard`, `admin`, `mail`, `static`, `cdn`, `docs`, `status`, `help`, `support`, and any name starting with `t-`. The form suggests a username from your email until you edit it. |
| Password | 8 to 72 characters with at least one uppercase letter, one lowercase letter and one digit. |
The username is your link subdomain. Every link you create is `https://acme.dectog.com/{slug}`, and the hint under the field reads “Your links live on acme.dectog.com”. There is no way to rename an account later.
Click **Create Account**. The next screen says “Account created” and shows two keys: **SDK key, for your app** (`dtg_pub_` and 32 hex characters), which goes into the SDK, and **Account key, for servers and scripts** (`dtg_` and 32 hex characters), which never goes into an app. Copy the account key now: it is shown only this once (**Settings** can replace it). The SDK key stays listed under **Settings**. **Sign up with Google** does the same after asking you to choose a username. If the Sign up tab is missing, or the server answers `Signup is currently closed`, signups are disabled on that deployment.
Self-hosting
On your own deployment the first account comes from `POST /api/admin/bootstrap`, not from the signup form. See [Local quick start](/self-hosting/local-quick-start/).
2. ## Register the app
[Section titled “Register the app”](#register-the-app)
In the dashboard open **Apps** and click **Add app**. The editor is titled “Register your first app” and only **App name** is required; every other field can be filled in later. The bottom bar’s **Add app** button saves.
| Section | Field | What it does |
| ------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| General | **App name** | Required. Brands the QR page and the mobile pages. |
| General | **Fallback URL (optional)** | The web page a tap lands on when nothing better exists. Must be an http(s) URL. |
| iOS | **iOS bundle ID**, **Apple Team ID** | Together they generate your Universal Links file (`apple-app-site-association`). The bundle ID looks like `com.company.app`; the Team ID is 10 uppercase letters or digits. |
| iOS | **App Store URL** | Where iPhone taps go when the app is not installed. Must be on `apps.apple.com`. |
| iOS | **iOS app scheme** | The custom scheme from `CFBundleURLSchemes`, typed without `://`. Powers the “Open in app” buttons. |
| Android | **Android package**, **SHA-256 fingerprints (one per line)** | Together they generate your App Links file (`assetlinks.json`). Fingerprints are 32 hex byte pairs separated by colons; include the Play App Signing key, not only your debug key. |
| Android | **Play Store URL** | Where Android taps go when the app is not installed. Must be on `play.google.com`. |
| Android | **Android app scheme** | The `android:scheme` of your intent filter. With the package it also enables the `intent://` bounce for unverified taps. |
Schemes may contain letters, digits, `+`, `-` and `.`; schemes the browser handles itself, such as `http`, `https` and `javascript`, are rejected. The first app you register is the account’s default app: links without an app, and all temporary links, route through it. See [Concepts](/start/concepts/).
3. ## Platform setup
[Section titled “Platform setup”](#platform-setup)
Both files are served per account at `https://acme.dectog.com/.well-known/...` as soon as the fields from step 2 are saved. The **Verify** buttons on the Apps page fetch the saved files and summarize what they contain.
* iOS
Add the Associated Domains capability to the Runner target with the entry for your link host:
ios/Runner/Runner.entitlements
```xml
com.apple.developer.associated-domains
applinks:acme.dectog.com
```
iOS downloads `https://acme.dectog.com/.well-known/apple-app-site-association` when the app is installed, so rebuild and reinstall after saving the bundle ID and Team ID. Test by tapping a link in Messages or Notes, not by typing it into Safari.
* Android
Add a verified `https` intent filter for your link host to the main activity. Intent filters cannot use wildcards, so list the host explicitly:
android/app/src/main/AndroidManifest.xml
```xml
```
Android verifies `https://acme.dectog.com/.well-known/assetlinks.json` at install time against the package and fingerprints you saved. Until verification passes, taps in Chrome still open the app through an `intent://` redirect, provided the app has both a scheme and a package. If you set an Android app scheme, add a second intent filter for it.
Custom-scheme links open only from a direct user tap; a redirect can never launch a scheme on iOS. The full walkthrough, including path-mode deployments, is in [Platform setup](/sdk/platform-setup/).
4. ## Add the SDK
[Section titled “Add the SDK”](#add-the-sdk)
The SDK is `flutter_dectog` 1.0.x. It needs Flutter 3.29 or newer (Dart 3.7), iOS 15 and Android 7 (API 24).
```bash
flutter pub add flutter_dectog
```
Initialize it once at startup with your SDK key, then listen to links and to the install verdict:
lib/main.dart
```dart
import 'package:flutter_dectog/flutter_dectog.dart';
final dectog = FlutterDectog();
Future initDectog() async {
await dectog.initialize(DectogConfig(
apiKey: 'dtg_pub_your_sdk_key', // Dashboard > Settings > SDK keys
));
// A link opened the running app (Universal Link, App Link or your scheme).
dectog.onLinkClick.listen((click) {
final route = click.clickParams?.deepLink; // dec_deeplink on the URL, if any
final target = click.targetUrl; // the link's fallback URL
print('${click.username}/${click.slug} route=$route target=$target');
});
// The link that launched the app. Delivered once per process.
final initial = await dectog.getInitialLink();
if (initial != null) {
print('Launched by ${initial.originalUrl}');
}
// The install verdict: on first launch, and again when a late signal upgrades it.
dectog.onAppInstalled.listen((install) {
final route = install.routing?.deepLink ?? install.deepLink;
if (route != null) {
// Medium confidence or better: route the new user to the content they tapped.
}
if (install.matched) {
// Guaranteed or high confidence: credit the install to install.slug.
}
});
}
```
`onAppInstalled` can emit more than once: a Play referrer that was not ready at first launch, or a pasted link, upgrades the verdict later (`claim == 'upgraded'`). `currentAppInstall` always holds the latest one.
Self-hosting
Point the SDK at your worker with `baseUrl` (the worker’s `PUBLIC_BASE_URL`). Add `linkRootDomain` when your links live on a different host than the API, for example API at `app.example.com` and links at `{username}.example.com`:
```dart
DectogConfig(
apiKey: 'dtg_pub_your_sdk_key',
baseUrl: 'https://app.example.com',
linkRootDomain: 'example.com',
)
```
Installation details, the dependency ranges and the Android build notes are in [Installation](/sdk/installation/).
5. ## Create a link
[Section titled “Create a link”](#create-a-link)
Open **Links** and click **New link**. The form is open already when the account has no links.
| Field | What to enter |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Fallback URL** | **App fallback URL (default)** uses the app’s Fallback URL from step 2; the option is disabled until that URL is set. **Custom URL** takes an http(s) URL for this link. |
| **Link URL** | The slug: letters, digits, `_` and `-`, up to 128 characters, unique across your permanent and temporary links. Enter `welcome`. |
| **App** | Shown once you have an app. **Default app** means the first app you registered. |
| **Expires** | Leave **Never**. Any other value creates a [temporary link](/links/temporary-links/). |
Click **Create link**. The list now shows `/welcome`; the copy icon next to it copies `https://acme.dectog.com/welcome`. **Advanced options** on the same form hold the per-platform deep-link paths, the Tracking defaults and the custom mobile and desktop destinations, all described in [How a tap is routed](/links/how-a-tap-is-routed/).
You can also create links from code: `dectog.createLink(slug: 'welcome', target: 'https://example.com/welcome')` in the SDK, or `POST /api/v1/links` with your account key and the request signature described in [REST API overview](/api/overview/).
6. ## Test an install
[Section titled “Test an install”](#test-an-install)
Use a device that has never had the app installed. A device the SDK has seen before reports a reinstall (`isReinstall: true`), which the Installs KPI leaves out. On an emulator, wipe its data between runs.
* Android
1. Open `https://acme.dectog.com/welcome` in Chrome on the phone. DecTog sends it to the Play Store with `referrer=dtg_click=&dtg_l=acme/welcome` on the store URL.
2. Install the app from the store and open it.
3. `onAppInstalled` emits a verdict with `matched: true`, `matchSource: 'referrer'`, `referrerSource: 'google_play'` and `matchConfidence: 'guaranteed'`.
A debug build pushed with `flutter run` never went through the store, so there is no referrer. The server then falls back to the fingerprint, which needs the tap and the first launch to come from the same network within the window (24 hours with a device model, 2 hours otherwise). The deterministic path needs a Play (or internal testing track) install.
* iOS
1. On the Apps page, turn on **iOS tap-to-store** under Advanced and save.
2. Open the link in Safari on the iPhone and tap **Get {app name}**. The page copies `https://acme.dectog.com/welcome?dtg_click=` to the pasteboard and opens the App Store.
3. Install the app (App Store or TestFlight) and open it. On iOS 16 and later the system asks to allow the paste; accept it.
4. `onAppInstalled` emits `matched: true`, `matchSource: 'clipboard'` and `matchConfidence: 'guaranteed'`.
Without tap-to-store, or when the user declines the paste, iOS installs rely on the fingerprint.
In the dashboard, **Overview** shows the install in the **Installs** card (“1 attributed”), in **Attribution quality** and in the **Match confidence & referrer health** panel. The **Links** list shows it in the **Installs** column of `/welcome`. The next launches of the app are not installs; a link that opens the installed app counts as an **Open**.
## Next steps
[Section titled “Next steps”](#next-steps)
[How attribution works](/start/how-attribution-works/)The signals, the confidence levels and what matched means.
[How a tap is routed](/links/how-a-tap-is-routed/)Every decision between the tap and the destination.
[Install attribution in the SDK](/sdk/install-attribution/)The verdict object, late claims and the install state.
[Testing](/sdk/testing/)Local loop, adb and simctl triggers, the end-to-end install test.
# Security
> How to report a vulnerability, and what is true today of DecTog's dependencies, builds and releases.
## Reporting a vulnerability
[Section titled “Reporting a vulnerability”](#reporting-a-vulnerability)
Report privately, never in a public issue: through GitHub’s private vulnerability reporting on the repository (the **Security** tab, **Report a vulnerability**), or by email to with `SECURITY` in the subject. A report is acknowledged within 48 hours, assessed within 5 working days, and a confirmed high or critical issue is fixed or mitigated within 30 days. Each repository’s `SECURITY.md` lists what is in scope.
## Dependencies and builds
[Section titled “Dependencies and builds”](#dependencies-and-builds)
What holds today, in the server (`dectog`), the Flutter SDK (`flutter_dectog`) and this site:
| | Server | SDK | Docs |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Lockfiles committed, installed exactly in CI | yes (`npm ci`) | the example app’s (`pubspec.lock`); a package leaves resolution to the app | yes (`npm ci`) |
| Weekly dependency updates (Dependabot), CI on each | npm, GitHub Actions | pub (package, companion package, example), Gradle, GitHub Actions | npm, GitHub Actions |
| Advisories checked in CI | `npm audit`: high or critical fails the Worker; critical fails the dashboard | `flutter pub outdated` printed on every run | `npm audit`: critical fails |
| GitHub Actions pinned to commit SHAs | yes | yes | yes |
| How a release ships | deployed by hand from a reviewed commit; no workflow in the server repository holds Cloudflare credentials | published by CI from a version tag that must match the package version, with pub.dev’s OIDC publishing (no long-lived token) | built and deployed by CI on every push to `main`, with a Cloudflare API token (`CLOUDFLARE_API_TOKEN`) |
The docs deploy token has the Workers Scripts: Edit permission, which Cloudflare grants on a whole account: in the account that also runs the server, it could deploy the server’s Worker too. Whoever can push to this site’s `main` or read its secrets holds that power; a separate Cloudflare account for the docs would take it away.
The dashboard and this site are static builds: their npm packages run only while they are built, on their own sources, so a high advisory there is a build-time risk, not a running one. The dashboard’s Next.js 15 bundles a PostCSS release with such advisories (GHSA-qx2v-qp2m-jg93, GHSA-6g55-p6wh-862q and two follow-ups), which need attacker-controlled CSS at build time; the Next.js 16 upgrade removes them.
DecTog has not made a tagged release yet. Release notes, the supported-versions table and the first pub.dev publish come with it.
# What DecTog does not do
> The limits of DecTog's attribution and analytics, stated plainly, so you can decide whether it fits before you integrate.
DecTog does deep linking and install attribution for your own links, on your own infrastructure. It is not a mobile measurement partner. This page lists what it does not do, so nobody finds out after integrating.
## Ad networks and Apple’s frameworks
[Section titled “Ad networks and Apple’s frameworks”](#ad-networks-and-apples-frameworks)
* **No SKAdNetwork or AdAttributionKit.** No conversion values, no postbacks, no ad-network attribution on iOS.
* **No ad-network clicks and no postbacks.** Ad networks cannot send DecTog their clicks or impressions server to server, and DecTog sends no postbacks to Meta Ads, Google Ads, TikTok Ads or any other network. An install from an ad is attributed only when the ad’s tap went through a DecTog link. Why, and when this would change: [Design decisions](/start/design-decisions/) (27).
* **Two ad-platform signals only.** The Apple Search Ads token (AdServices, resolved with Apple) and Meta’s on-device install referrer from the Facebook and Instagram apps, which the SDK reads on Android when the app has a Facebook App ID (`DectogConfig.fbAppId`, else the one saved on the Apps page). The referrer credits the DecTog link it names, not an ad.
* **Advertising identifiers only on request, and never matched on.** The core SDK never reads the GAID or the IDFA and never shows the App Tracking Transparency prompt; an app that wants them adds the separate `flutter_dectog_adid` package and asks for tracking itself. The server stores the id per install and joins it to nothing: attribution never uses it. There is no identity graph and no cross-app or cross-account identity: device ids are vendor-scoped and stay inside one account.
## Probabilistic matching
[Section titled “Probabilistic matching”](#probabilistic-matching)
DecTog tries deterministic signals first: the store install referrer on Android, the pasteboard and the paste button on iOS, Meta’s referrer, and Apple Search Ads. Only when none of them names a click does it fall back to a **probabilistic match**: a scored comparison of the install with recent mobile web clicks from the same hashed IP address and device family. See [How attribution works](/start/how-attribution-works/).
* **It can be wrong.** Two phones of the same model on one Wi-Fi network look alike. DecTog credits a probabilistic match only at high confidence and credits nobody when taps on different links tie, but a confident match is still a guess, not proof.
* **It is time-bound.** Candidates are clicks from the last 24 hours when both the click and the install carry a device model and the install is not on a cellular network, and from the last 2 hours otherwise. Both windows are deployment settings, and the short one can be set per app.
* **Apple’s rules.** Apple’s user-privacy rules for developers prohibit fingerprinting, meaning deriving data from a device, its configuration included, to identify it, whatever the user consented to. DecTog’s match compares a salted IP hash, the device model, OS version, language, screen size and network inside one account. By default it also records the device’s text size, dark mode, Increase Contrast and Reduce Motion settings and its UTC offset, on the tap and on the install, and compares them for no points, together with the language’s region and, on Android, the WebView build in the user agent. Those are device configuration, read to tell phones apart, which is what Apple’s rule names. Nothing is combined into an identifier and nothing is matched across accounts, but whether this is acceptable for your app is your call, and the App Store review risk is yours. On iOS the pasteboard read, the paste button and Apple Search Ads are the deterministic paths; prefer them.
* **Accessibility settings can be health data.** Increase Contrast, Reduce Motion and a large text size can suggest a disability, which EU data-protection law may treat as health data. An app that cannot carry that leaves the SDK’s device tier out (the app then sends none of the four settings, but still sends its UTC offset, like its time zone) or turns **Probabilistic match** off (the fingerprint never runs, nothing is compared, and the app’s interstitial and tap-to-store pages stop reading the settings). Leaving out the device tier alone does not change the link pages: they still record the settings on the tap. See [Device data and privacy](/sdk/device-data-and-privacy/#device-settings).
* **The probabilistic match can be turned off per app.** On the Apps page, Advanced, **Probabilistic match** off means the app’s installs are matched by deterministic signals only (store referrer, copied link, Meta referrer) and the comparison never runs, not even as a shadow. Strict fraud mode is a different switch: it refuses credit to installs that trip a fraud rule.
## Attribution model
[Section titled “Attribution model”](#attribution-model)
* **No multi-touch.** Each install is credited to at most one click, and each event to one link: the link that last re-opened the app, inside its app’s re-engagement window (7 days by default), else the install’s link. A user who reinstalls is matched again like any install, carries `is_reinstall: true` and is left out of the Installs count.
* **No uninstall tracking.**
* **Only `dec_*` parameters survive an install.** Other query parameters on a shared link are not stored, so they do not come back after a deferred install; carry an in-app route in `dec_deeplink`. See [URL parameters](/links/url-parameters/#your-own-parameters).
## Analytics and data
[Section titled “Analytics and data”](#analytics-and-data)
* **Detail is kept for the retention window only.** Per-link totals survive it in the daily rollups, but raw taps, installs, opens and events older than `RETENTION_DAYS` (400 by default) are deleted once the rollups hold their day, so breakdowns by country, referrer, campaign or device model cover that window. See [Scheduled jobs and retention](/self-hosting/scheduled-jobs-and-retention/).
* **Revenue is the app’s word unless a store confirms it.** A purchase the app reports is stored as sent, unless it carries a StoreKit 2 transaction (`appStoreJws`) or arrives through the App Store’s or Google Play’s notifications. Google Play renewals are not recorded from Play’s notifications, which carry no price. Amounts are converted to the account’s base currency with the ECB’s daily rates.
* **Breakdowns take at most two dimensions at a time,** and the raw export leaves out IP addresses and user agents. See [Export](/api/export/).
## Platform and hosting
[Section titled “Platform and hosting”](#platform-and-hosting)
* **No native iOS (Swift) or Android (Kotlin) SDK.** The SDKs are for [Flutter](/sdk/installation/) and [React Native and Expo](/react-native/), plus `dectog.js` for your own web pages ([Web pixel](/sdk/web/)); an app written in Swift or Kotlin calls the [REST API](/api/overview/) itself. Neither SDK is on a package registry yet: they are published with the first release.
* **Custom link domains need the deployment’s support,** one per account: Cloudflare for SaaS on its zone and three settings. Until then the Settings card says it is not set up. See [Custom domains](/links/custom-domains/).
* **Three roles, no single sign-on.** A team has owners, members and viewers; everyone signs in with their own email and password or Google. There is no SAML, SCIM or per-app permission.
* **Pre-release.** The API and the SDK may still change without a deprecation period until the first public release.
The design choices behind several of these limits, and the alternatives that were rejected, are in [Design decisions](/start/design-decisions/).