---
name: vinktar-setup
description: Wire Vinktar product analytics and error tracking into a codebase. Sends the first event over the HTTP API, links identity, connects error tracking through a Sentry SDK's DSN, and verifies data is actually flowing. Use when adding, auditing or extending Vinktar instrumentation.
---

# Vinktar setup

Vinktar is a Mixpanel-style event analytics platform with a built-in error
tracker. This skill wires it into a codebase and proves the data arrived.

**Two things to confirm before writing code.** Ask once if the repo does not
answer them, then proceed.

1. **The write key.** It looks like `vnk_pk_…`. If it was not given to you, it is
   in the product under **Settings → Project → API keys**. Take a key whose scope
   is `write`; a `read` key returns `403 write_scope_required`, which is the usual
   cause of a setup that looks correct and sends nothing.
2. **The value moment.** The single most important action in this product
   (`Signed up`, `Order completed`, `Report generated`). You cannot track what you
   cannot name.

**Integration path.** Write a small internal client against the HTTP API below.
Native SDKs (`@vinktar/browser`, `@vinktar/node`, `vinktar/php` — those three, and
no others at launch) are still in internal testing and are **not on their
registries yet**, so do not install or import them, and do not invent an API for
them. For errors specifically, a Sentry SDK pointed at a Vinktar DSN works today
and is the better path for any stack that already ships one.

The full wire contract is at [/api.md](/api.md). Read it for anything this file
omits.

---

## Pre-flight scan

Read the repo before asking anything:

- `package.json` / `composer.json` — the stack, and whether a Vinktar client or an
  internal wrapper already exists.
- App entry point — where init goes.
- Auth handlers (login, signup, logout, session restore) — where identity goes.
- Routes / controllers — candidate events and their properties.
- Existing env config — how secrets are injected, so the write key follows suit.

Produce a short tracking plan from that and confirm it, rather than asking
questions the code already answers.

**Operating modes.** Default is *Quick Start*: two events live (the value moment
plus one more) and identity on login, in one pass. *Add tracking*: match the
naming and identity conventions already in the codebase. *Audit*: produce a
prioritised fix list, identity bugs first.

---

## Step 1 — Prove the pipe before touching app code

Do this first, always. Debugging init, identity and transport at once makes
failures impossible to isolate.

```sh
curl -sS https://in.vinktar.com/v1/batch \
  -H 'Content-Type: application/json' \
  -H "X-Vinktar-Key: $VINKTAR_WRITE_KEY" \
  -d '{"batch":[{"name":"Signed up","user_id":"user_123","payload":{"plan":"pro"}}]}'
```

**A 202 does not mean your event was accepted.** Assert the body:

```sh
… | jq -e '.rejected == 0 and (.errors | length) == 0 and .received == 1 and (has("identify_ignored") | not)'
```

That is the success check everywhere in this file. Three traps it exists to catch:

- `received` counts **accepted events plus every identify entry**, including ones
  the same response reports as ignored. `received: 1` from an identify-only
  request can mean nothing happened. Always read `identify_ignored` too.
- A malformed event still returns 202, with the reason in `errors[]`.
- `/v1/errors` additionally returns `suppressed`, and its `received` counts only
  errors that survived the inbound filter. See [Error tracking](#error-tracking).

Other statuses: `4xx` means fix the request and do not retry; `5xx` means retry
with backoff; `429` carries `Retry-After` (read the warning in **Never do**
before writing a retry loop).

**Self-hosted installs** have their own ingest host. Take it from the install
screen's snippets rather than assuming `in.vinktar.com`, and probe it with
`GET /v1/health`.

---

## Step 2 — The HTTP contract

`POST /v1/batch`, header `X-Vinktar-Key: vnk_pk_…`, JSON body:

```json
{
  "batch": [
    {
      "name": "Signed up",
      "user_id": "user_123",
      "payload": { "plan": "pro" },
      "context": { "current_url": "https://example.com/signup" }
    }
  ],
  "identify": [{ "device_id": "d1", "user_id": "user_123", "$set": { "plan": "pro" } }]
}
```

**Event fields.** `name` is required. Optional: `payload` (free-form properties),
`context` (see below), `event_id` (supply one to make retries idempotent — reuse
the *same* id when retrying the *same* event), `timestamp`, `device_id`,
`user_id`, `session_id`.

**`payload` vs `context`.** `payload` is your properties. `context` is a fixed set
of well-known keys that land in typed columns and drive the fast filters and
breakdowns: `current_url`, `referrer`, `utm_source` / `utm_medium` /
`utm_campaign` / `utm_term` / `utm_content`, `os`, `browser`, and friends. Put URL
and campaign data in `context`, not `payload`, or you lose those filters with no
way to tell. The exact key list is in [/api.md](/api.md).

**Limits before you write a loop:** 1,000 items per request (events and identify
entries combined), 5 MiB per request, ≤255 properties per event (`payload` and
`context` counted together), ≤255 bytes per string value, ≤3 levels of nesting,
timestamps within `[-7 days, +1 hour]`. Over the batch or body cap is `413` —
split and resend; retrying the same payload fails identically. `Content-Encoding:
gzip` is accepted on the event and error endpoints (**not** on `/v1/sourcemaps`).

**Setting traits without an event:** `POST /v1/identify` takes `user_id` plus
either a `device_id` or at least one trait operation. This is how a backend sets
traits with no event attached.

---

## Step 3 — The client

One small file, e.g. `lib/vinktar.{ts,js,php}`, matching the project's module and
env conventions. Read the key from `VINKTAR_WRITE_KEY` (or the framework's public
variant: `VITE_VINKTAR_WRITE_KEY`, `NEXT_PUBLIC_VINKTAR_WRITE_KEY`). Add it to
`.env.example`, confirm `.env` is gitignored, never inline it in a committed file.

### Browser

```ts
// lib/vinktar.ts — device id + session + track + identify.
const KEY = import.meta.env.VITE_VINKTAR_WRITE_KEY;
const HOST = 'https://in.vinktar.com';

function deviceId(): string {
  let id = localStorage.getItem('vk_device');
  if (!id) {
    id = crypto.randomUUID();
    localStorage.setItem('vk_device', id);
  }
  return id;
}

let userId: string | null = null;

function send(body: object) {
  const json = JSON.stringify(body);
  // sendBeacon survives page unload; it cannot set headers, hence the ?_k= form.
  // The parameter is exactly `_k`: anything else is ignored and the request 401s.
  if (
    !navigator.sendBeacon?.(
      `${HOST}/v1/batch?_k=${encodeURIComponent(KEY)}`,
      new Blob([json], { type: 'application/json' })
    )
  ) {
    fetch(`${HOST}/v1/batch`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Vinktar-Key': KEY },
      body: json,
      keepalive: true,
    }).catch(() => {});
  }
}

export function track(name: string, payload: Record<string, unknown> = {}) {
  send({ batch: [{ name, payload, device_id: deviceId(), user_id: userId ?? undefined }] });
}

export function identify(id: string, traits: Record<string, unknown> = {}) {
  userId = id;
  localStorage.setItem('vk_user', id);
  send({ identify: [{ device_id: deviceId(), user_id: id, $set: traits }] });
}

// Call on logout. A device stays bound to the FIRST user it identified as, so
// without a fresh device id the next person's anonymous activity is attributed
// to the previous one.
export function reset() {
  userId = null;
  localStorage.removeItem('vk_user');
  localStorage.removeItem('vk_device');
}
```

Restore `userId` from `localStorage` in a browser-only effect, not at module
scope, so it does not run during SSR.

### Framework placement

This is where setups usually go wrong. The API shape is rarely the problem.

- **Next.js App Router.** Init cannot live in a Server Component. Put the client in
  a `'use client'` module and import it from client components only. For route
  handlers and server actions, use the server template below with the same key.
- **Any SSR framework.** `localStorage` and `crypto.randomUUID()` do not exist
  during render. Create the device id inside an effect or a browser guard, never at
  module scope.
- **React StrictMode.** Effects run twice in dev. Make init idempotent or you will
  chase phantom duplicate pageviews.
- **SPA route changes.** A pageview on `load` fires once for the whole session.
  Subscribe to the router instead.
- **Ad blockers.** A browser-side send can be blocked outright. If the browser path
  shows nothing, prove the key with the cURL in Step 1 before touching app code.

### Server

```ts
// lib/vinktar.ts — you supply the user id.
const KEY = process.env.VINKTAR_WRITE_KEY!;

export async function track(
  name: string,
  payload: Record<string, unknown>,
  userId: string
): Promise<void> {
  await fetch('https://in.vinktar.com/v1/batch', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Vinktar-Key': KEY },
    body: JSON.stringify({ batch: [{ name, payload, user_id: userId }] }),
  }).catch(() => {}); // analytics must never take the request down
}
```

```php
<?php
// src/Vinktar.php — fire and forget with a short timeout.
final class Vinktar
{
    public function __construct(private readonly string $writeKey) {}

    /** @param array<string, mixed> $payload */
    public function track(string $name, array $payload, string $userId): void
    {
        $ch = curl_init('https://in.vinktar.com/v1/batch');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 2,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'X-Vinktar-Key: '.$this->writeKey,
            ],
            CURLOPT_POSTFIELDS => json_encode([
                'batch' => [['name' => $name, 'payload' => $payload, 'user_id' => $userId]],
            ]),
        ]);
        curl_exec($ch); // deliberately ignore failures
        curl_close($ch);
    }
}
```

Other languages take the same shape: POST JSON, set the header, assert the
response during development, swallow failures in production paths.

### Naming

Place each `track()` call next to the action it represents — in the event handler,
the form-submit callback, the API endpoint — not in a generic wrapper.

- **One casing, forever.** Names are case-sensitive: `Order completed` is not
  `order_completed`. Default to `Title case` names and `snake_case` property keys
  unless the codebase already has a convention.
- **One event, one meaning.** Never reuse a name for two actions.
- **Numbers as numbers.** `{ total: 49 }`, not `{ total: '49' }`, or it cannot be
  aggregated.
- **Omit empty properties.** No `null`, `''` or `'N/A'`.

---

## Step 4 — Identity

Two ids and nothing else. **`device_id`** is the device; **`user_id`** is the
person. Sending both on an `identify` links them, and because identity resolves at
query time, that person's earlier anonymous activity joins their history
retroactively. No backfill.

**Traits.** `identify` carries trait operations, and they are stored and queryable
(`user.<key>` in the SQL editor, and as a dashboard filter or breakdown):

- `$set` — last write wins. The value now.
- `$set_once` — first write wins. For what you cannot reconstruct: signup date,
  first referrer, original plan.
- `$unset` — remove a key. Sending `null` does not remove.

Reserved traits are `$`-prefixed: `$email`, `$name`, `$username`, `$avatar`,
`$created`. Bare spellings are normalised, so `email` works. Values are flat
scalars, ≤255 bytes. Anything dropped comes back in the response as
`traits_dropped` with a reason.

**Rules:**

- Use a **stable primary key** (a database id) as the user id, never an email.
- **Order on signup:** create the user, send the identify, then
  `track('Signed up', …)` with the `user_id`, so the signup event attributes.
- **Call `reset()` on logout** in the browser. First link wins, so a shared device
  otherwise attributes the next person's activity to the previous one.
- Server-side, set `user_id` on every event; no `device_id` needed.

Do not wire identity until at least one plain event is confirmed arriving.

---

## Error tracking

**Preferred: reuse a Sentry SDK.** If the project already uses one, point its DSN
at Vinktar and you are done. Errors arrive through the Sentry-compatible envelope
endpoint; transactions, sessions and replays are acknowledged and discarded.

```
https://<write_key>@in.vinktar.com/<projectId>
```

`<projectId>` is a number and is **not** part of the write key. The exact DSN for a
project is shown in-product under **Settings → Project → Install**, on the
"Migrate from Sentry" tab, and on the install screen of a project that has not
received its first event.

**Direct: `POST /v1/errors`** (batch of ≤50, same auth). Its 202 body is
`{received, rejected, suppressed, errors}`. `received` counts only errors that
survived the inbound filter, which silently suppresses the usual noise
(`ResizeObserver loop…`, `Script error.`, `Non-Error promise rejection captured`,
and stacks made entirely of browser-extension frames). A suppressed test message
returns `{"received":0,"rejected":0,"suppressed":1}`, which is **not** a broken
key. Use a distinctive message when smoke-testing:

```bash
curl -sS https://in.vinktar.com/v1/errors \
  -H 'Content-Type: application/json' -H "X-Vinktar-Key: $VINKTAR_WRITE_KEY" \
  -d '{"errors":[{"exceptions":[{"type":"TestError","value":"hello from curl",
       "stack":[{"file":"app.js","function":"main","line":1,"col":1,"in_app":true}]}],
       "mechanism":{"type":"manual","handled":true},"release":"test@0.0.1"}]}'
```

Set a `release` (e.g. `web@1.2.0`) on errors: it ties issues to deploys and drives
regression detection. Source maps upload to `/v1/sourcemaps` with a `cli`-scoped
key (see [/api.md](/api.md) — that endpoint has its own required fields and does
not accept gzip). Late uploads re-symbolicate recent errors automatically.

---

## Done means

Check every one of these before reporting success. All but the last are checkable
without opening the product.

- [ ] The smoke test passed the `jq` assertion in Step 1.
- [ ] At least one event fires from real application code, and its response passes
      the same assertion.
- [ ] On login, an `identify` carrying both `device_id` and `user_id` is sent, and
      `reset()` is wired to logout.
- [ ] The write key is read from env. `git diff` shows no `vnk_pk_` literal.
- [ ] `AGENTS.md` has a **Vinktar** section: the integration path in use, the env
      var name, the events with their properties, and where identity is wired.
- [ ] In the product, **Events** shows the event name with the expected properties.

Zero results almost always means a casing or typo mismatch in the event name, or
the wrong key. Check those two first.

---

## Never do

- **Never install or import an unpublished `@vinktar/*` or `vinktar/*` package**,
  and never invent an API for one.
- **Never honour `Retry-After` blindly on a `429`.** The `monthly_cap_exceeded`
  and `monthly_error_cap_exceeded` codes set it to the first of next month, so a
  naive loop sleeps for weeks. Branch on the reason code.
- **Never send request headers beyond** `Content-Type`, `Content-Encoding` and
  `X-Vinktar-Key`. Browser CORS on `/v1` allows exactly those; anything else fails
  preflight. In particular the key is not an `Authorization` bearer.
- **Never build event names dynamically** (`track('clicked_' + id)`). It creates
  thousands of junk names. Put the variable part in a property.
- **Never put PII in `payload`** — no emails, tokens, full names, addresses.
- **Never wrap `track()` in a generic abstraction layer.** It hides the call sites
  that reviews depend on and invites dynamic names.
- **Never mint a fresh `event_id` per retry attempt.** It is the idempotency key;
  a new one turns a retry into a duplicate.
- **Never send `null` to clear a trait.** Use `$unset`.
- **Never use an email as `user_id`.** Emails change.
- **Never gzip a `/v1/sourcemaps` upload.** That endpoint does not inflate bodies.
- **Never wire feature flags to Vinktar.** There is no `/v1/flags` endpoint and no
  flags product.
- **Never let an analytics failure propagate into a request path.**
- **Never touch CI, deploy or infrastructure config** as part of this setup. If the
  write key has no obvious home, stop and ask.

---

## Wrap up

Suggest two or three more events tied to the product's key funnel, then stop. A
small, correct, consistent tracking plan beats an exhaustive noisy one.

## Quick reference

- Write key: `vnk_pk_…`, scope `write`, from **Settings → Project → API keys**.
  Read from `VINKTAR_WRITE_KEY`; never commit.
- Ingest host: `https://in.vinktar.com` (self-hosted installs differ).
- Events: `POST /v1/batch`, header `X-Vinktar-Key`. Success is `rejected: 0`,
  `errors: []`, `received` equal to what you sent, and no `identify_ignored`.
- Traits without an event: `POST /v1/identify`.
- Errors: Sentry DSN `https://<key>@in.vinktar.com/<projectId>`, or
  `POST /v1/errors` (also returns `suppressed`).
- Wire contract: [/api.md](/api.md).
