Hjarni REST API
The REST API is for scripts, automations, and custom integrations. If you are connecting an AI assistant like ChatGPT or Claude, use the MCP server instead.
Overview
- Base URL
https://hjarni.com/api/v1- Auth
- Bearer token
- Content type
application/json- Resources
- Me, dashboard, search, notes (including file attachments), containers, and tags
- Rate limit
- 100 requests per minute per token. Exceeding it returns
429with aRetry-Afterheader.
Authentication
- Go to Settings > Connections in Hjarni.
- Create a token for your integration.
- Send it in the
Authorizationheader as a Bearer token.
curl https://hjarni.com/api/v1/notes \
-H "Authorization: Bearer YOUR_API_TOKEN"
Pagination
List endpoints accept page (default 1) and per_page (default 25, max 100). Two exceptions return the full list without pagination: GET /containers/:id/children and GET /notes/:id/linked. Metadata is returned in response headers:
X-Total-Count- Total number of records matching the query
X-Page- Current page number
X-Per-Page- Items per page
Me
/api/v1/me
The account behind the token: user and plan, onboarding state, team memberships, and note limits, plus a token block describing the token itself. Folder-scoped tokens get only the user and token blocks.
Response example
{
"user": {"id": 1, "first_name": "Evert", "email_address": "you@example.com", "plan": "pro"},
"token": {"id": 7, "name": "smoke-test", "scope_container": null},
"onboarding": {"completed": true, "missing_steps": [], "signals": {...}},
"teams": [{"id": 3, "name": "Acme", "role": "owner", "notes_used": 12, "notes_limit": 25}],
"limits": {"notes_used": 42, "notes_limit": null},
"suggestions": {"create_team": false}
}
Dashboard
/api/v1/dashboard
Overview of your personal knowledge base with counts and recent notes.
Response example
{
"inbox_count": 3,
"notes_count": 42,
"archived_count": 7,
"containers_count": 8,
"tags_count": 12,
"recent_notes": [
{
"id": 1,
"title": "Weekly review",
"body": "# Decisions\n\n...",
"summary": "Review of the week",
"summary_stale": false,
"source_url": null,
"archived": false,
"favorited": false,
"position": 1,
"lock_version": 2,
"created_at": "2026-03-15T10:00:00Z",
"updated_at": "2026-03-15T10:00:00Z",
"tag_list": "review,planning",
"tags": [{"id": 1, "name": "review"}, {"id": 2, "name": "planning"}],
"container": {"id": 5, "name": "Work"},
"files": []
}
]
}
Search
/api/v1/search
Full-text search across notes. Returns paginated note objects.
q- Search query string. Omitting it (or sending it blank) returns the unfiltered scope.
search_scope"personal","all"(default), or"team:<id>"for a specific team.page,per_page- Pagination. Defaults to page 1, 25 per page.
exclude_bodytrueomits thebodyfield from each result. Fetch full notes individually withGET /notes/:id.
GET /api/v1/search?q=weekly+planning&search_scope=personal
Notes
/api/v1/notes
List personal notes. Paginated.
scope"archived","inbox","favorited", or omit for active notes. Trashed notes have their own endpoint:GET /notes/trash.container_id- Filter by container.
tag- Filter by tag name.
q- Full-text search within listed notes.
page,per_page- Pagination.
exclude_bodytrueomits thebodyfield from each note, so sync clients can page through metadata cheaply and fetch full notes one at a time withGET /notes/:id.
/api/v1/notes/inbox
Notes without a container. Paginated. Same response shape as listing notes, including exclude_body.
/api/v1/notes/:id
Full note with body, tags, container, files, and linked note IDs.
Each file's url is a signed link that expires 15 minutes after the response is generated, at the time given in url_expires_at. It needs no token of its own, so treat it as a credential and don't store it: re-request the note for a fresh link rather than saving the URL. A sync client that pages through notes first and downloads attachments afterwards should fetch the note again at download time.
Response example
{
"id": 1,
"title": "Weekly review",
"body": "# Decisions\n\nSee [[42:Project plan]].",
"summary": "Review of the week",
"summary_stale": false,
"source_url": "https://example.com/source",
"archived": false,
"favorited": true,
"position": 1,
"lock_version": 4,
"created_at": "2026-03-15T10:00:00Z",
"updated_at": "2026-03-15T12:30:00Z",
"tag_list": "review,planning",
"tags": [
{"id": 1, "name": "review"},
{"id": 2, "name": "planning"}
],
"container": {"id": 5, "name": "Work"},
"files": [
{
"id": 10,
"description": "Slide deck",
"filename": "slides.pdf",
"content_type": "application/pdf",
"byte_size": 204800,
"url": "https://hjarni.com/rails/active_storage/blobs/...",
"url_expires_at": "2026-03-15T12:45:00Z"
}
],
"linked_note_ids": [42, 58]
}
/api/v1/notes
Create a note. Returns 201 on success.
title- Required. Note title.
body- Markdown content. Supports
[[id:Title]]wiki-links. summary- Short summary for quick scanning.
source_url- Canonical source URL.
container_id- Place the note in a container. Omit for inbox.
tag_list- Comma-separated tag names, e.g.
"review,planning". position- Sort position within the container. Omit to leave unpositioned; unpositioned notes sort before positioned ones.
POST /api/v1/notes
{
"note": {
"title": "Weekly review",
"body": "# Weekly review\n\nKey decisions...",
"summary": "Review of the week",
"source_url": "https://example.com/source",
"container_id": 12,
"tag_list": "review,planning"
}
}
/api/v1/notes/:id
Update any note field. Only include the fields you want to change.
PATCH /api/v1/notes/1
{
"note": {
"body": "Updated content",
"tag_list": "review,done"
}
}
Optional expected_lock_version guards against concurrent edits. Every note read returns a lock_version integer. Echo it back on your write and the server rejects with 409 Conflict if someone else saved in the meantime.
PATCH /api/v1/notes/1
{
"note": {
"body": "Updated content",
"expected_lock_version": 5
}
}
# 409 Conflict response when the version no longer matches:
{
"error": "Note was modified since you last read it. Re-read and re-apply...",
"current_lock_version": 7,
"last_edited_by": "alice@example.com",
"last_edited_by_agent": null,
"last_edited_at": "2026-05-27T14:32:00Z"
}
Surgical body edits are anchor-based and stay safe with or without the version: append_body, replace_find (with replace_with, which deletes the match when omitted), and insert_body (with insert_after or insert_before naming the anchor line). Pass expected_lock_version whenever you also touch non-body fields and want a stale-write guard.
For long notes, prefer the structured patch operations over a full-body replacement — they target a heading or checklist item instead of the whole document, so a stale read can't clobber unrelated sections. Each targets exactly one match and is mutually exclusive with the other body modes.
replace_section+section_body- Replace everything under a heading (with or without leading
#), keeping the heading line. The section runs to the next same-or-higher-level heading. append_to_section+section_body- Add content at the end of a section — the safe way to insert under a heading without knowing its last line.
rename_heading+new_heading- Rename a heading in place. The level is preserved unless
new_headingcarries its own#markers. check_item/uncheck_item- Tick or untick a checklist item by its text (e.g.
ship it). Matched case- and whitespace-insensitively.
PATCH /api/v1/notes/1
{
"note": {
"append_to_section": "## Open Questions",
"section_body": "- Should we ship Friday?"
}
}
/api/v1/notes/:id
Move a note to Trash. Returns 204. The note is recoverable — it stays restorable until it is purged, and can be brought back with POST /notes/:id/restore (see History & Trash below).
Note actions
PATCH /notes/:id/archive
Archive a note. Returns the updated note.
PATCH /notes/:id/unarchive
Restore an archived note. Returns the updated note.
PATCH /notes/:id/favorite
Mark a note as favorited. Returns the updated note.
PATCH /notes/:id/unfavorite
Remove from favorites. Returns the updated note.
PATCH /notes/:id/move
Move to another container. Send {"container_id": 5}.
Note links
POST /notes/:id/link
Link two notes bidirectionally. Send {"target_note_id": 42}. Returns the source note.
DELETE /notes/:id/unlink
Remove the link. Send {"target_note_id": 42}. Returns 204.
GET /notes/:id/linked
List all notes linked to this note. Returns an array of note objects.
Note files
/api/v1/notes/:id/files
Attach a file to a note by URL. Hjarni fetches the URL (following redirects) and stores the result. Free accounts can attach up to 20 MB across five personal attachments; uploads beyond that allowance get 403 with an upgrade_url. Returns 201.
url- Required. The file to fetch. Missing or blank returns
422{"error": "url is required"}. filename,content_type- Optional overrides; otherwise derived from the URL and response headers.
description- Optional description shown with the attachment.
POST /api/v1/notes/1/files
{"url": "https://example.com/slides.pdf", "description": "Slide deck"}
# 201 response:
{"id": 10, "filename": "slides.pdf", "content_type": "application/pdf",
"byte_size": 204800, "description": "Slide deck"}
POST /notes and PATCH /notes/:id also accept a nested note_files_attributes array for attaching as part of a create or update.
History & Trash
Every note keeps an append-only revision history, attributed to whoever made each edit (you, an AI agent, or the system). Deletes are recoverable from Trash until they are purged. See Note history and Trash for the concepts behind these endpoints.
GET /notes/:id/history
List revisions, newest first. Each entry carries seq, at, a one-line summary, what changed, and a by block (label, kind, client, agent, source). Accepts limit (1–100, default 50), before_seq for paging, and include_body=true to inline each reconstructed body.
GET /notes/:id/history?seq=N
Reconstruct one revision in full, including its body as it stood at that point.
POST /notes/:id/revert
Revert the note to an earlier revision. Send {"seq": N}. This writes a new revision (it never rewrites history) with reverted_from_seq set. Optional expected_lock_version rejects with 409 Conflict if the note changed since you read it. Returns the updated note.
GET /notes/trash
List trashed notes, most recently deleted first. Each entry adds deleted_at, purge_after, and deleted_by.
POST /notes/:id/restore
Bring a trashed note back. Returns the restored note. A note that was deleted together with its folder is not restorable on its own (404): restore the folder instead and the note comes back with it.
Containers
/api/v1/containers
List containers. Paginated. Returns root-level containers by default.
scope"archived","all"(all active, flat), or omit for active roots only.
Response example
[
{
"id": 5,
"name": "Work",
"description": "Day job projects",
"position": 1,
"archived": false,
"root": false,
"llm_instructions": "Use formal tone.",
"created_at": "2026-03-01T09:00:00Z",
"updated_at": "2026-03-15T10:00:00Z",
"notes_count": 12,
"children_count": 3,
"parent": null
}
]
/api/v1/containers/:id
Single container with counts and parent info.
/api/v1/containers
Create a container. Returns 201.
name- Required. Container name.
description- Optional description.
parent_id- Nest under a parent container. Omit for root.
llm_instructions- AI instructions scoped to this container.
position- Sort position among siblings. Omit to leave unpositioned; unpositioned containers sort before positioned ones.
POST /api/v1/containers
{
"container": {
"name": "Research",
"description": "Papers and reading notes",
"parent_id": 5
}
}
/api/v1/containers/:id
Update name, description, parent, position, or instructions.
/api/v1/containers/:id
Move a container to the Trash, taking its sub-folders and their notes with it as one recoverable unit. Restorable for 30 days via PATCH /containers/:id/restore, then permanently removed. Returns 204, or 409 if the container was already deleted by a concurrent request.
Container actions
PATCH /containers/:id/archive
Archive. Optional include_notes=true also archives every note in the folder in one bulk update, and include_nested=true sweeps nested sub-folders too. Returns the updated container plus containers_changed and notes_changed counts.
PATCH /containers/:id/restore
Bring a deleted container back from the Trash, together with everything its delete took. Returns the restored container. A container that is not in the Trash answers 404; 409 means it left the Trash mid-request (restored elsewhere, or purged).
PATCH /containers/:id/unarchive
Restore. Takes the same include_notes and include_nested options, which reactivate everything they touch, including items archived individually before. Restoring a nested folder also reactivates its archived ancestors so it stays reachable. Returns the updated container plus the change counts.
GET /containers/:id/children
Direct child containers. Returns an array of container objects.
GET /containers/:id/notes
Notes in this container. Paginated.
GET /containers/:id/tree
Container with its ancestor chain and direct children.
PATCH /containers/reorder
Reorder containers. Send {"ordered_ids": [3,1,2]} with all IDs in the scope. Optional parent_id to reorder children of a specific container. Returns 204.
Root instructions
GET /containers/root_instructions
Get your personal root AI instructions. Returns {"personal_llm_instructions": "..."}.
PATCH /containers/update_root_instructions
Update root instructions. Send {"personal_llm_instructions": "..."}.
Errors
401
Missing or invalid token.
403
Two cases. Plan-gated actions (exceeding the Free plan note limit, file uploads beyond the free attachment allowance) return an error message plus upgrade_url. Folder-scoped tokens reaching outside their scope (inbox, tag writes, other folders) return {"error": "Forbidden: outside token scope"} with no upgrade_url.
404
Resource not found or not accessible by your account.
422
Validation failed. Model validations return an errors array with messages; parameter checks (e.g. reorder's ordered_ids, a missing file url) return a single error string.
429
Rate limited: more than 100 requests in a minute on one token. Response is JSON with an error message and a Retry-After header saying when to try again.
Questions about the API?
Email evert@hjarni.com and we'll help.
Give your AI a memory. Free.
Connect Claude or ChatGPT to notes they can actually read and write.
Give your AI a memory. Free.