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-datawith the fieldpayload(JSON text) and an optional filescreenshot(image/jpegorimage/png, up to 1 MB). Without a screenshot,application/jsonwith 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 casesGET /api/v1/configanswersactive: falseand 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 andAccept: 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: falsemeans the component shows no tab. Every answer, also 401 and 429, hasCache-Control: no-store. 401 invalid_keyfor an unknown key: no tab, one line in the developer log.429 rate_limitedfor 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
dryRunon, 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 statesgrace,lockedandinactive.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 |
kindsis merged:{"kinds": {"specific": false}}leavesgeneralas it is. Fortexts, a language and key that isnull(or only spaces) removes that text; a language set tonullremoves both of its texts:{"texts": {"nl": {"thanks": null}}}.- The body is strict. An unknown key at any level gives
400 invalid_payloadwith the path. A body withoutkindsandtextsgives 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_payloadand the field and the reason. - Only
Authorization: Bearerworks here, not the dashboard session. An app of another account gives 404. - Send
X-Nitpick-Client: cli/<version>ormcp/<version>if you are a tool of your own and wantupdated_viato say so; anything else is stored asapi.
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
POST /api/v1/cli/sessionswith{client_name}returns{session_id, user_code, verification_url, expires_at, interval}.user_codelooks likeABCD-1234, is valid for 30 minutes.session_idis secret.- 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. - Poll
POST /api/v1/cli/sessions/{session_id}/tokeneveryintervalseconds (2).202 {"status": "pending"},200 {"token": "npt_...", "account": {email}}(exactly once), or410withexpiredordenied. While the account has no running subscription, polling keeps answering202 pending; the token follows when it runs. Allowing for an account at the limit of 25 tokens answers409 limit_reached.