# API reference Version 1. All agent endpoints return JSON. Errors look like `{"error": {"code": "...", "message": "..."}}`. Base address: `https://app.nitpickhq.com` by default (a placeholder that never resolves, until a real domain is chosen). Locally the platform runs on port 3000 of your own machine. ## Two kinds of keys - **Public app key** `npk_...`: ships inside your app, not secret, can only create reports for one app. - **Agent token** `npt_...`: secret, belongs to your account, can read and change status on all your apps. Stored hashed on the server. ## Ingest: app to platform `POST /api/v1/feedback` - Header `X-Nitpick-Key: npk_...` (required). - Body: `multipart/form-data` with the field `payload` (JSON text) and an optional file `screenshot` (`image/jpeg` or `image/png`, up to 1 MB). Without a screenshot, `application/json` with the payload as body also works. - Answers: `201 {"id": ""}`, `400 invalid_payload`, `401 invalid_key`, `403 app_inactive`, `413 too_large`, `429 rate_limited`, `429 monthly_limit`. - `403 app_inactive`: the subscription ended more than 44 days ago. `429 monthly_limit`: the account reached 2,000 reports this month. In both cases `GET /api/v1/config` answers `active: false` and the component shows "Feedback is not available right now." See [Billing and limits](/docs/billing). - `413 too_large`: the image is larger than 1 MB (or wider or taller than 4096 pixels), or the whole request is too large. - Rate limits: 30 reports per minute per app and 10 per minute per IP address. The components only send a report after the user opened the form themselves and tapped Send. ## App settings: app to platform `GET /api/v1/config` - Header `X-Nitpick-Key: npk_...` (required). The component sets only this header and `Accept: application/json`. The operating system adds the IP address and a User-Agent to every request, as it does for any request. - Answer: `200 {"active": true, "kinds": {"general": true, "specific": true}, "texts": {"nl": {"thanks": "..."}}}`. `active: false` means the component shows no tab. Every answer, also 401 and 429, has `Cache-Control: no-store`. - `401 invalid_key` for an unknown key: no tab, one line in the developer log. `429 rate_limited` for too many unknown keys from one IP address. - The component asks once when the app starts, and again when the app comes back to the foreground if the last successful request is more than an hour old. Changes reach your users within a minute of that. The platform keeps nothing about the request: it uses the IP address and User-Agent only to answer it. - Unknown fields, languages and kinds are ignored. A missing kind counts as `true`. A text that is not a string or is longer than 140 characters is skipped, and the component's own translation shows instead. - With `dryRun` on, the component asks for nothing. ## Agent API Header `Authorization: Bearer npt_...` (required). A token only sees apps of its own account; the id of another account's object gives 404. | Request | Purpose | Answer | |---|---|---| | `GET /api/v1/me` | Who am I | `{account: {id, email}, subscription, usage}` | | `GET /api/v1/apps` | Your apps | `{apps: [{id, name, platform, public_key, created_at, open_count, locked_count}]}` | | `POST /api/v1/apps` body `{name, platform}` | Create an app | `201` with the app | | `GET /api/v1/apps/{id}/settings` | Settings of an app | `{kinds, texts, updated_at, updated_via}` | | `PATCH /api/v1/apps/{id}/settings` body `{kinds?, texts?}` | Change the settings | The new settings | | `GET /api/v1/feedback` | List | `{items: [Report], next_cursor, locked_count}` | | `GET /api/v1/feedback/{id}` | One report | Report | | `GET /api/v1/feedback/{id}/screenshot` | The image | image bytes, or 404 | | `PATCH /api/v1/feedback/{id}` body `{status}` | Set status | Report | | `GET /api/v1/stats?app_id=` | Overview | `{by_screen: [{screen, open, total}], by_element: [{screen, element, open, total}], by_version: [{app_version, open, total}], locked_count}` | | `GET /api/v1/tokens` | Tokens of your account | `{tokens: [{id, name, prefix, created_at, last_used_at, revoked_at, current}]}` | | `DELETE /api/v1/tokens/{id}` | Revoke a token | `204` | | `DELETE /api/v1/tokens/current` | Revoke the token of this request | `204` | Tokens: `prefix` is the first 8 characters (`npt_` plus 4). `current` is `true` for the token that made the request. A token name is text someone typed, so treat it as data. Revoking works at once. `/api/v1/tokens` accepts only `Authorization: Bearer`. ## Subscription and usage `GET /api/v1/me` returns `subscription: {status, paid_until, locks_at, deletes_locked_at, deletes_unlocked_at, locked_count, renew_url}`. `status` is `active`, `grace`, `locked` or `inactive`; the times are ISO times in UTC, or null when the account never paid. `locked_count` is the number of locked reports of the whole account. `usage` is `{month, count, limit}` for the current calendar month (UTC); `limit` is 2,000. Every answer of the agent API has two headers when they apply: - `Nitpick-Subscription: status=; locks_at=; locked_count=; renew_url=` in the states `grace`, `locked` and `inactive`. - `Nitpick-Usage: month=; count=; limit=; resets_at=` from 80% of the monthly limit. Locked reports are never in `items` and never in a filter result. `locked_count` in `GET /api/v1/feedback` is the number of locked reports of the app in `app_id`, or of all apps without it. All other filters are ignored for that number, and it is the same on every page. `open_count` does not include locked reports. One locked report asked by id answers `402 subscription_required` with no field of the report. Errors you can meet on the agent API, next to `400`, `401`, `404` and `429 rate_limited`: | Answer | When | |---|---| | `402 subscription_required` | The account has no running subscription and you create an app or a token, change a status in the state `inactive`, or ask for a locked report. Only renewing helps: do not retry. | | `403 app_inactive` | Ingest for an app of an account whose subscription ended more than 44 days ago. | | `429 monthly_limit` | Ingest above 2,000 reports in the calendar month. Accepted again from the first of the next month. | | `409 limit_reached` | Creating an app or token above 50 apps or 25 tokens that are not revoked. The error has `resource` (`apps` or `tokens`) and names what can be cleaned up. | | `413 too_large` | Ingest with an image above 1 MB. | See [Billing and limits](/docs/billing) for the terms. Filters for `GET /api/v1/feedback`, all optional: `app_id`, `status` (`open` default, `resolved`, `all`), `kind`, `screen`, `element`, `app_version`, `platform`, `since`, `limit` (1 to 100, default 25), `cursor`. Newest first. ## The report | Field | Type and rule | |---|---| | `id` | uuid | | `app_id`, `app_name` | the app | | `kind` | `general` or `specific` | | `comment` | 1 to 2000 characters, with at least one character that is not a space. Required for both kinds | | `screen`, `element` | names from your markers, up to 200 characters, or null | | `element_match` | `exact`, `nearest` or `none` | | `element_frame` | `{x, y, width, height}` in points, origin top left of the window, or null | | `tap` | `{x, y}` in points, or null | | `viewport` | `{width, height, scale}`. The screenshot is `width*scale` by `height*scale` pixels, or a scaled copy with the same ratio | | `has_screenshot`, `screenshot_url` | whether an image exists, and where | | `platform` | `ios` or `android` | | `sdk` | `{name: "swift" or "react-native", version}` | | `app_version`, `build` | text or null | | `device` | `{model, os_version, locale}`. Model is an identifier such as `iPhone16,1`. Never the name the user gave the device | | `status` | `open` or `resolved` | | `resolved_at`, `created_at`, `client_ts` | ISO times | Example report JSON: ```json { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "kind": "specific", "comment": "Too small to click", "screen": "checkout", "element": "checkout.pay_button", "element_match": "exact", "element_frame": {"x": 100, "y": 200, "width": 80, "height": 44}, "tap": {"x": 120, "y": 215}, "viewport": {"width": 390, "height": 844, "scale": 1}, "platform": "ios", "sdk": {"name": "swift", "version": "0.2.0"}, "app_version": "1.0", "status": "open", "device": {"model": "iPhone16,1", "os_version": "18.0", "locale": "en-US"}, "client_ts": "2026-10-01T12:00:00Z", "created_at": "2026-10-01T12:00:01Z" } ``` Feedback is always anonymous: no name, email, account id or device id of the end user. ## Settings `GET /api/v1/apps/{id}/settings` returns `{kinds, texts, updated_at, updated_via}`. `PATCH` takes `{kinds?, texts?}` and returns the new settings. | Field | Type and rule | |---|---| | `kinds` | `{general: boolean, specific: boolean}`. Both default to `true`. Both `false` means no tab | | `texts` | Language code to `{footer?, thanks?}`. `footer` is the sentence under Send, `thanks` the thank-you after sending. Codes are case sensitive: `en`, `nl`, `de`, `fr`, `es`, `pt`, `it`, `pl`, `tr`, `ru`, `uk`, `sv`, `da`, `nb`, `ja`, `ko`, `zh-Hans`, `zh-Hant`, `ar`, `hi` | | `updated_at` | ISO time, or null if nothing was ever changed | | `updated_via` | `dashboard`, `cli`, `mcp`, `api` or null | - `kinds` is merged: `{"kinds": {"specific": false}}` leaves `general` as it is. For `texts`, a language and key that is `null` (or only spaces) removes that text; a language set to `null` removes both of its texts: `{"texts": {"nl": {"thanks": null}}}`. - The body is strict. An unknown key at any level gives `400 invalid_payload` with the path. A body without `kinds` and `texts` gives 400. - A text is 1 to 140 characters and may not contain links, domain names, email addresses, phone numbers, markup or invisible characters. The server refuses these with `400 invalid_payload` and the field and the reason. - Only `Authorization: Bearer` works here, not the dashboard session. An app of another account gives 404. - Send `X-Nitpick-Client: cli/` or `mcp/` if you are a tool of your own and want `updated_via` to say so; anything else is stored as `api`. Changes reach your users within a minute, the next time the app starts or comes back after more than an hour in the background. See [Settings](/docs/settings). ## Logging in from the CLI 1. `POST /api/v1/cli/sessions` with `{client_name}` returns `{session_id, user_code, verification_url, expires_at, interval}`. `user_code` looks like `ABCD-1234`, is valid for 30 minutes. `session_id` is secret. 2. Open `verification_url`. The user logs in or creates an account, starts the subscription if there is none (the page waits until the payment is confirmed), sees the code and the client name and presses Allow or Deny. Without a running subscription there is no token. 3. Poll `POST /api/v1/cli/sessions/{session_id}/token` every `interval` seconds (2). `202 {"status": "pending"}`, `200 {"token": "npt_...", "account": {email}}` (exactly once), or `410` with `expired` or `denied`. While the account has no running subscription, polling keeps answering `202 pending`; the token follows when it runs. Allowing for an account at the limit of 25 tokens answers `409 limit_reached`.