Neatkept API for developers
Your inventory is yours, and you can reach it from your own scripts: import a spreadsheet, keep a nightly backup, show a box's contents on a home dashboard or feed a no-code tool. The API is the same one the Neatkept apps use, at https://api.neatkept.com/v1, and a personal API token opens the part of it meant for scripts.
- API reference: every call a token can make.
- OpenAPI spec: the same calls as OpenAPI 3.0, for Postman, Swagger UI or a code generator.
Getting started
- Sign in to the web app at app.neatkept.com and open Settings → API tokens (on iPhone and iPad, Settings → API tokens opens the same page).
- Choose New token. Give it a name you'll recognise later ("Nightly backup"), pick the access and when it expires (30, 90 or 365 days, or never).
- Read sees everything in the household and changes nothing.
- Read and write can also add, edit, move and delete. It needs you to be an editor or the owner; a viewer can make read tokens only.
- Copy the token. It starts with
nk_pat_and is shown once; Neatkept keeps only a fingerprint of it.
A token belongs to you and one household. It never does more than you can: if you become a viewer, a write token reads only; if you leave the household, it stops working. A token that goes unused for a year stops working too. The tokens page shows when each was last used, and Revoke turns one off at once.
The first request: the household the token belongs to.
export NEATKEPT_TOKEN=nk_pat_...
curl -s https://api.neatkept.com/v1/households \
-H "Authorization: Bearer $NEATKEPT_TOKEN"
{"items": [{"id": "0192f1a4-…", "name": "Home", "role": "editor", …}]}
That id is the {hid} in every other path. Its places, boxes and things:
H=https://api.neatkept.com/v1/households/0192f1a4-...
AUTH="Authorization: Bearer $NEATKEPT_TOKEN"
curl -s -H "$AUTH" $H/locations
curl -s -H "$AUTH" $H/containers
curl -s -H "$AUTH" "$H/items?limit=50"
Keep a token like a password: in an environment variable or a secret store, never in code you share. Anyone holding it can do what it allows.
How writes work
Every row has an ID that you choose, a UUID, and you write it with PUT …/{id}:
- A new ID creates the row; a known one replaces its fields. Sending the same body again changes nothing, so a script that stopped halfway can simply run again.
- The answer is the row as stored, with its
version. SendIf-Match: "7"to change it only if nobody changed version 7 meanwhile (412 and the current row otherwise), orIf-None-Match: *to create it and never overwrite one. DELETEis a soft delete: the row stays as a tombstone withdeleted_at. Writing a deleted row answers 410; for places, boxes and things?restore=truebrings it back.
A thing (item) lies in exactly one place: in a box (container_id) or directly in a place (location_id), such as a shelf. A place (location) sits in a tree: property → room → storage → zone → shelf.
Example: import things from a CSV
things.csv has a box code, a name, a quantity and tags:
box,name,quantity,tags
W01,Cordless drill,1,tools;bosch
W01,Drill bits set,2,tools
K07,Christmas lights,3,decorations;winter
The script reads the boxes once, then writes each line under an ID made from the box and the name (uuid5), so a rerun updates the same things instead of adding copies. It needs pip install requests.
import csv, os, re, sys, time, uuid
import requests
API = "https://api.neatkept.com/v1"
HID = os.environ["NEATKEPT_HOUSEHOLD"]
s = requests.Session()
s.headers["Authorization"] = "Bearer " + os.environ["NEATKEPT_TOKEN"]
NS = uuid.UUID("6f1c7e2a-3b9d-4c1e-9a5f-2d8b7c6e4f10") # any fixed UUID of your own
def call(method, path, **kw):
while True:
r = s.request(method, API + path, **kw)
if r.status_code == 429: # over the rate: wait as long as asked
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
if r.status_code >= 400:
problem = r.headers.get("Content-Type", "").startswith("application/problem+json")
sys.exit(f"{method} {path}: {r.status_code} {(r.json().get('detail') or r.json()['title']) if problem else r.text}")
return r.json() if r.content else None
boxes, cursor = {}, None
while True:
page = call("GET", f"/households/{HID}/containers", params={"limit": 500, "cursor": cursor})
boxes.update({c["code"]: c["id"] for c in page["items"] if not c.get("deleted_at")})
cursor = page.get("next_cursor")
if not cursor:
break
with open("things.csv", newline="") as f:
for row in csv.DictReader(f):
box = re.sub(r"[\s._-]", "", row["box"]).upper() # stored like this: "w-01" is W01
if box not in boxes:
sys.exit(f"no box {box}")
item_id = uuid.uuid5(NS, box + "/" + row["name"])
call("PUT", f"/households/{HID}/items/{item_id}", json={
"container_id": boxes[box],
"name": row["name"],
"quantity": float(row["quantity"] or 1),
"tags": [t for t in row["tags"].split(";") if t],
})
print("ok", row["box"], row["name"])
A write token is needed, and the boxes must exist (make them in the app, or with PUT …/containers/{id}).
Reading changes and backups
The lists of places, boxes, things and declutter rules (locations, containers, items, declutter-rules) take ?updated_since= and return the rows changed after that instant, deleted ones included (with deleted_at). Pages hold up to 500 rows (limit); follow next_cursor until it is absent. Moves are a log that only grows, newest first: page through …/moves until you reach one you already have.
A nightly backup that fetches only what changed:
import json, pathlib
state = pathlib.Path("since.txt")
since = state.read_text().strip() if state.exists() else None
newest = since
with open("items.jsonl", "a") as out:
cursor = None
while True:
params = {"limit": 500, "cursor": cursor, "updated_since": since}
page = call("GET", f"/households/{HID}/items", params=params)
for it in page["items"]:
out.write(json.dumps(it) + "\n")
newest = max(newest or it["updated_at"], it["updated_at"])
cursor = page.get("next_cursor")
if not cursor:
break
if newest:
state.write_text(newest)
(call is the helper from the import example.) Times in answers are rounded to the millisecond, so the newest row of one run may come again in the next. The last line for an ID wins; a line with deleted_at means the thing was deleted. For a full copy with photos, use Settings → Export in the app.
Errors
Errors come as problem details (application/problem+json):
{
"type": "https://neatkept.com/problems/token-read-only",
"title": "Read-only token",
"status": 403,
"detail": "this token can only read",
"request_id": "449f049d8af0c082…"
}
detail is safe to show to a person; quote request_id (also the X-Request-ID header) if you write to us. The ones a script meets:
| Status | What it means |
|---|---|
| 400, 422 | The request or a field is wrong; errors lists the fields. |
| 401 | The token is unknown, revoked or expired (WWW-Authenticate: Bearer). |
| 403 | problems/token-read-only: a write with a read token. problems/token-not-allowed: a call tokens can't make (sign-in, members, billing, lock codes, AI capture). Otherwise your role is too low, e.g. a write token of someone who has become a viewer. |
| 404 | Not in this household (another household's ID is a 404 too). |
| 409 | The ID belongs to another household, or something it points to isn't there yet. |
| 410 | The row is deleted; for places, boxes and things ?restore=true brings it back. |
| 412 | Changed since your If-Match version; the body has the current row. |
| 429 | Over the rate (problems/rate-limited): wait Retry-After seconds. |
Limits
Each token has its own rate, per minute:
| Plan | Tokens per person and household | Requests | Search and duplicates |
|---|---|---|---|
| Free | 2 | 60 | 10 |
| Pro, Family | 10 | 300 | 30 |
Every answer within the rate carries RateLimit-Limit and RateLimit-Remaining. Over the rate the API answers 429 with Retry-After; wait that long and go on.
Photos
Photos don't pass through the API; they go straight to storage, as JPEG (at most 20 MiB):
PUT /v1/households/{hid}/photos/{id}with{"size_bytes": …, "checksum_sha256": "<base64>", "item_id": "<the thing>"}. The answer hasupload: a URL and the headers to send.PUTthe file toupload.urlwith exactly those headers.POST /v1/households/{hid}/photos/{id}/uploaded. The photo turnsreadyonce its thumbnail is made.
SIZE=$(wc -c < drill.jpg | tr -d ' ')
SUM=$(openssl dgst -sha256 -binary drill.jpg | base64)
curl -s -X PUT "$H/photos/$PHOTO_ID" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "{\"size_bytes\": $SIZE, \"checksum_sha256\": \"$SUM\", \"item_id\": \"$ITEM_ID\"}"
GET …/photos/{id} gives short-lived URLs to read a photo and its thumbnail.
AI assistants (MCP)
Neatkept works in Claude, ChatGPT and other assistants that speak the Model Context Protocol. Ask "where are the kids' skis?", "do I already have a label printer?" or "put the drill in box W03", and the assistant answers from your inventory or changes it, as you would in the app.
The server's URL is:
https://mcp.neatkept.com/mcp
Connecting signs you in to Neatkept and opens a page where you pick one household and the access: Read, or Read and change (add, edit, move and delete). The assistant never gets more than your role there. To switch to another household, connect again: the new choice replaces the old one.
- Claude: on claude.ai or in the desktop app, add a custom connector in the settings' Connectors section with this URL, then connect; it then works on your phone too. In Claude Code:
claude mcp add --transport http neatkept https://mcp.neatkept.com/mcp, then/mcpto sign in. - ChatGPT (on the web): open Plugins, select + → Add custom MCP server, enter a name and this URL, choose OAuth, confirm the warning, then Create as a plugin and sign in. Mention it in a chat with @Neatkept. Which ChatGPT plans can add a custom server, and with changes or only reads, is up to OpenAI: see its help on custom MCP servers.
- Others: any client with remote MCP over Streamable HTTP and OAuth sign-in. Personal API tokens don't work on the MCP server, and MCP sign-ins don't work on
/v1.
The tools:
search_things: find things by what they are; answers where each one is.get_thing: one thing in full, with its recent moves.list_places: the places with their full paths (up to 300; browse further withlist_contents).list_contents: what's in a place or a box, optionally everything under it.resolve_code: what a label code like W01 belongs to, and what's in it.find_duplicates: whether you already have something before you buy it.list_with_me: what you've taken with you.list_moves: recent moves.add_things: add things to a box or a place.update_thing: change a thing's name, quantity, tags, notes or value.move: move a thing or a box, or take it with you.create_place,update_place,create_box,update_box: set up places and boxes.delete_thing,delete_box,delete_place: delete; assistants usually ask before. It can be undone.restore: bring back something deleted.
Changes happen at once, as in the app. Each connection has its own rate, as a token does, and each tool call counts once (search_things and find_duplicates against the search rate); see Limits above. Connections don't count toward the number of tokens. A connection unused for 90 days signs out: connect again.
What the assistant reads goes to its company under its own terms; see the privacy policy. To disconnect, open Settings → Connected apps in the web app (the apps link to it), or remove Neatkept in the assistant.
What a token can't do
Sign-in and the account, members and invites, subscriptions, lock codes and AI capture from photos or voice stay in the apps. Search (POST …/search) and the duplicate check work with tokens.
Stability
/v1 doesn't break: an app built today keeps working, and so does your script. New calls, new optional parameters and new fields in answers can appear at any time, so ignore fields you don't know. A call or field is removed only after an announcement on this page at least 6 months ahead. Every change to the API is checked against the shipped apps before it goes out.
Changes
- 17 October 2026: AI assistants over MCP.
- 17 October 2026: personal API tokens and these pages.