Neatkept API for developers

Last updated: 17 October 2026

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.

Getting started

  1. 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).
  2. 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.
  3. 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 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:

StatusWhat it means
400, 422The request or a field is wrong; errors lists the fields.
401The token is unknown, revoked or expired (WWW-Authenticate: Bearer).
403problems/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.
404Not in this household (another household's ID is a 404 too).
409The ID belongs to another household, or something it points to isn't there yet.
410The row is deleted; for places, boxes and things ?restore=true brings it back.
412Changed since your If-Match version; the body has the current row.
429Over the rate (problems/rate-limited): wait Retry-After seconds.

Limits

Each token has its own rate, per minute:

PlanTokens per person and householdRequestsSearch and duplicates
Free26010
Pro, Family1030030

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):

  1. PUT /v1/households/{hid}/photos/{id} with {"size_bytes": …, "checksum_sha256": "<base64>", "item_id": "<the thing>"}. The answer has upload: a URL and the headers to send.
  2. PUT the file to upload.url with exactly those headers.
  3. POST /v1/households/{hid}/photos/{id}/uploaded. The photo turns ready once 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.

The tools:

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