Skip to content

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": "<uuid>"}, 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.
  • 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=<status>; locks_at=<ISO>; locked_count=<n>; renew_url=<url> in the states grace, locked and inactive.
  • Nitpick-Usage: month=<YYYY-MM>; count=<n>; limit=<n>; resets_at=<ISO> 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 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:

{
  "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/<version> or mcp/<version> 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.

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.