API reference
Search & OCR API
Full-text search across your notes and everything Harbor's OCR has read out of your attachments — plus the exact on-page coordinates of every match, so you can draw the highlights yourself.
Harbor search runs against a per-user FTS5 index, ranked by BM25. It covers two
endpoints: GET /search runs a query and returns ranked note and attachment
hits with snippets, and GET /search/coordinates returns the on-page bounding
boxes of matched words inside a single attachment — the endpoint behind the
yellow inline OCR highlights you see in Harbor search.
Both endpoints require a bearer token with the
search scope and only ever see the
authenticated user’s own data.
Encryption never leaks through search. Encrypted notes and encrypted attachments (and any attachment owned by an encrypted note) are never indexed, OCR’d, or returned by either endpoint. The index stores no plaintext for them, so they cannot surface through results, snippets, or coordinates. Notebook names stay plaintext, so the
notebook:filter keeps working.
The query grammar
The q parameter is an Evernote-style query string. Tokens are
whitespace-separated:
| Token | Meaning |
|---|---|
bareword | Free-text term. Multiple free-text terms AND together. |
term* | A trailing * is a prefix match — recei* matches receive, receipt, … |
"exact phrase" | Quoted words match consecutively and in order. |
-token | Negates any token (free-text, phrase, tag:, notebook:, intitle:). |
tag:VALUE | Note carries this tag. Values are folded — lower-cased, diacritics stripped, whitespace → _. Use tag:"two words" for multi-word tags. A tag: matching no tag yields no results. |
notebook:VALUE | Note is in this notebook, matched by exact id first, else case-insensitive name. No match yields no results. |
intitle:VALUE | Term must appear in the note title. Notes only — attachments have no title. |
resource:RTYPE | Note owns a live, non-encrypted attachment of this type: image, pdf, audio, application, or any. |
created:RANGE | Filter by creation date (forms below). |
updated:RANGE | Filter by last-updated date. |
Date ranges for created: / updated: come in three forms, all evaluated
in UTC with inclusive bounds:
YYYYMMDD— a single day, e.g.created:20260615YYYYMMDD..YYYYMMDD— a span, e.g.updated:20260101..20260131day-N— the last N days including today, e.g.updated:day-7
A few semantics worth knowing:
- Negation always subtracts.
report -draftis thereportset minus anything also matchingdraft. A pure-negation query like-spamreturns everything except matches — not nothing. - Filters-only queries are valid.
tag:finance resource:pdfwith no free-text term runs the structural filters on their own. - You can’t inject FTS operators. User terms are quoted and escaped before they reach FTS5, so input is always treated as a literal term or phrase.
- A query that parses to nothing matchable at all — empty, whitespace, or a
stray operator with no value — returns
422 validation_failed.
Search notes and attachments
GET /api/v1/search · scope: search
Run a query and return the matching note and/or attachment hits, ranked by relevance.
| Field | Type | Required | Description |
|---|---|---|---|
q | string | Yes | The query string (grammar above). Empty, whitespace, or unmatchable → 422. |
types | string (CSV) | No | Hit types to return; each value note or attachment. Default note,attachment. |
notebook_id | string | No | Hard filter to one notebook, ANDed with any notebook: operator in q. |
limit | integer | No | Default 100, hard cap 500 (clamped, not errored). |
offset | integer | No | Default 0. Invalid values fall back to 0. |
order | string | No | relevance (default), updated_at, -updated_at, created_at, -created_at. |
snippet | boolean | No | Default true. false skips snippet/highlight generation — snippet comes back empty and highlights as []. |
curl -G "https://app.harbor.my/api/v1/search" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
--data-urlencode 'q=budget tag:finance -draft created:day-30' \
--data-urlencode 'types=note,attachment' \
--data-urlencode 'limit=20'
The response is the standard { data, paging } collection envelope. Each
element is a note hit or an attachment hit:
{
"data": [
{
"type": "note",
"note_id": "9c2e7b10-4f6d-4a2e-9b1c-3d8e5f7a0c21",
"notebook_id": "5b1f2c9a-8e3d-4b7f-a1c6-2f9d0e4b8a53",
"title": "Quarterly plan",
"snippet": "…the <em>budget</em> for Q3…",
"highlights": ["budget"],
"score": 8.42,
"tags": ["finance", "planning"],
"created_at": 1749000000000,
"updated_at": 1750000000000
},
{
"type": "attachment",
"resource_id": "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"note_id": "9c2e7b10-4f6d-4a2e-9b1c-3d8e5f7a0c21",
"note_title": "Quarterly plan",
"notebook_id": "5b1f2c9a-8e3d-4b7f-a1c6-2f9d0e4b8a53",
"mime": "application/pdf",
"filename": "invoice.pdf",
"snippet": "…total <em>budget</em> 12,500…",
"highlights": ["budget"],
"score": 3.07,
"has_coordinates": true,
"created_at": 1749000000000,
"updated_at": 1750000000000
}
],
"paging": { "limit": 100, "offset": 0, "total": 2, "has_more": false }
}
Fields on every hit:
| Field | Type | Description |
|---|---|---|
type | string | note or attachment. |
notebook_id | string | The containing notebook. |
snippet | string | HTML-escaped excerpt with matched terms wrapped in <em>…</em>. Empty with snippet=false. |
highlights | array | Deduped, lower-cased matched terms — always an array, never null. Re-highlight without parsing the snippet. |
score | number | BM25 relevance score. |
created_at | integer | UTC epoch milliseconds. |
updated_at | integer | UTC epoch milliseconds. |
Note hits add note_id, title, and tags. Attachment hits add:
| Field | Type | Description |
|---|---|---|
resource_id | string | The attachment’s resource id. |
hash | string | The sha256 content address. Fetch the file’s bytes and key highlight overlays by hash, not resource_id — see the Files API. |
note_id | string | The owning note. |
note_title | string | The parent note’s title, so a UI can render “a file within a note” (note title primary, filename beneath). "" when the attachment is in no live note. Always plaintext — encrypted-note attachments are excluded from search entirely. |
mime | string | The attachment’s MIME type. |
filename | string | The original filename. |
has_coordinates | boolean | true means OCR word boxes exist for this attachment, so you can call GET /search/coordinates for highlight rectangles. |
Ranking. order=relevance (the default) ranks by BM25. Note matches score
across three weighted columns — title 10.0, body 1.0, tags 4.0.
Attachment (OCR) scores are scaled by a 0.6 boost, so OCR hits rank below
direct note hits. Ties break by updated_at descending, then id ascending,
for stable paging. The result window is bounded by the 500 hard cap — total
never exceeds 500 — and snippets/highlights are computed only for the
requested page.
Errors
422 validation_failed— empty/whitespace/unmatchableq, a malformed operator value (bad date range, unknownresource:type), an unknowntypesvalue, or an unknownordervalue.detailsnames the offending field.limit > 500is clamped, not errored.
Python
import os
import requests
BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
# Search notes and attachments: free text + tag + date-range operators.
resp = requests.get(
f"{BASE}/search",
headers=HEADERS,
params={
"q": "budget tag:finance -draft created:day-30",
"types": "note,attachment",
"limit": 20,
},
)
resp.raise_for_status()
for hit in resp.json()["data"]:
if hit["type"] == "note":
print(f"note: {hit['title']} (score {hit['score']})")
else:
# Attachment hit — an OCR match inside a file.
print(f"attachment: {hit['filename']} in \"{hit['note_title']}\"")
if hit["has_coordinates"]:
print(" has word boxes — /search/coordinates works")
JavaScript
const BASE = "https://app.harbor.my/api/v1";
// Search notes and attachments: free text + tag + date-range operators.
const params = new URLSearchParams({
q: "budget tag:finance -draft created:day-30",
types: "note,attachment",
limit: "20",
});
const res = await fetch(`${BASE}/search?${params}`, {
headers: { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` },
});
if (!res.ok) throw new Error(`search failed: ${res.status}`);
const { data, paging } = await res.json();
for (const hit of data) {
if (hit.type === "note") {
console.log(`note: ${hit.title} (score ${hit.score})`);
} else {
console.log(`attachment: ${hit.filename} in "${hit.note_title}"`);
}
}
console.log(`${paging.total} total, has_more=${paging.has_more}`);
Go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
const base = "https://app.harbor.my/api/v1"
// searchHit is the subset of hit fields this example prints.
type searchHit struct {
Type string `json:"type"`
Title string `json:"title"`
Filename string `json:"filename"`
NoteTitle string `json:"note_title"`
Score float64 `json:"score"`
HasCoordinates bool `json:"has_coordinates"`
}
func main() {
// Search notes and attachments: free text + tag + date-range operators.
params := url.Values{}
params.Set("q", "budget tag:finance -draft created:day-30")
params.Set("types", "note,attachment")
params.Set("limit", "20")
req, err := http.NewRequest("GET", base+"/search?"+params.Encode(), nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("HARBOR_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var out struct {
Data []searchHit `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
for _, hit := range out.Data {
if hit.Type == "note" {
fmt.Printf("note: %s (score %.2f)\n", hit.Title, hit.Score)
} else {
fmt.Printf("attachment: %s in %q\n", hit.Filename, hit.NoteTitle)
}
}
}
Get OCR highlight coordinates
GET /api/v1/search/coordinates · scope: search
Return the bounding boxes of OCR-matched words on a single attachment, grouped
by page, so you can draw highlight overlays on the rendered document or image.
This is the endpoint that powers Harbor’s yellow inline OCR highlights: search
for a word, open a scanned PDF from the results, and the word is marked right
on the page — these boxes drew that. Use it with the has_coordinates: true
attachment hits from GET /search.
Online vs. offline OCR search. The coordinates endpoint is online-only — per-word boxes never sync. The searchable OCR text does sync (as the pull-only
ocr_textrecord — see the Sync API), so a native client can search inside attachments offline, then call this endpoint online when the user opens a hit and wants the highlight overlay.
| Field | Type | Required | Description |
|---|---|---|---|
resource_id | string (UUID) | Yes | The attachment’s resource id. Missing → 422; unknown, another user’s, or tombstoned → 404. |
q | string | One of q/terms | A query string (full grammar). Its positive free-text and phrase terms become the highlight targets; operators (tag:, notebook:, dates) are ignored here. |
terms | string (CSV) | One of q/terms | Literal terms to highlight, e.g. terms=budget,q3. Takes precedence over q. |
page | integer | No | Restrict to a single 0-based page index (non-negative; invalid → 422). |
max_boxes | integer | No | Default 1000, hard cap 2000 (clamped). Total word boxes + phrase-span words; when the cap clips output, truncated is true. |
At least one of q / terms must be supplied and yield a usable term, or the
request is a 422.
curl -G "https://app.harbor.my/api/v1/search/coordinates" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
--data-urlencode 'resource_id=0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40' \
--data-urlencode 'q=budget "q3 plan"'
The response is a single { data } resource with one entry per page that had
at least one hit:
{
"data": {
"resource_id": "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40",
"mime": "application/pdf",
"page_count": 3,
"terms": ["budget", "q3 plan"],
"pages": [
{
"page": 0,
"page_width": 1700,
"page_height": 2200,
"matches": [
{
"term": "budget",
"word": "Budget",
"word_index": 42,
"box": { "x": 510.0, "y": 880.0, "w": 120.0, "h": 28.0 },
"norm": { "x": 0.3, "y": 0.4, "w": 0.0706, "h": 0.0127 },
"confidence": 0.98
}
],
"phrases": [
{
"phrase": "q3 plan",
"word_indexes": [43, 44],
"boxes": [
{ "x": 640.0, "y": 880.0, "w": 70.0, "h": 28.0 },
{ "x": 716.0, "y": 880.0, "w": 90.0, "h": 28.0 }
],
"norm_union": { "x": 0.376, "y": 0.4, "w": 0.0976, "h": 0.0127 }
}
]
}
],
"truncated": false
}
}
Top-level fields:
| Field | Type | Description |
|---|---|---|
resource_id | string | The attachment you asked about. |
mime | string | The attachment’s MIME type. |
page_count | integer | The attachment’s OCR page count. 0 when no OCR result row exists. |
terms | array | The resolved highlight terms, after folding. |
pages | array | Only pages that produced hits, ordered ascending. |
truncated | boolean | true when the max_boxes cap clipped the output — more matches exist than were returned. |
Each page carries page (0-based index), page_width / page_height in
pixels when known (omitted when the OCR result lacked page dimensions), and
two arrays:
| Field | Type | Description |
|---|---|---|
matches[].term | string | The requested term this word matched. |
matches[].word | string | The actual OCR text on the page. |
matches[].word_index | integer | 0-based per-page word index. |
matches[].box | object | Pixel rectangle {x, y, w, h}. Omitted when page pixel dimensions are unknown. |
matches[].norm | object | Normalized rectangle, always present, clamped to [0,1]. Multiply by your rendered page size to place the highlight at any zoom. |
matches[].confidence | number | OCR confidence for the word. |
phrases[].phrase | string | The matched phrase. |
phrases[].word_indexes | array | The contiguous word indexes of the span. |
phrases[].boxes | array | Per-word pixel rectangles (omitted when pixel dimensions are unknown). |
phrases[].norm_union | object | One normalized rectangle covering the whole span — draw a single highlight per phrase. |
Term matching. Terms are folded the same way the index folds words —
lower-cased, NFKC-normalized, diacritics stripped — and matched on the same
sub-tokens the FTS index produces. The unicode61 tokenizer splits on
anything that isn’t a letter, digit, or _, so the single OCR word
(503)884-4426 is matched by a query for 4426, and García, still matches
its bare term. Porter stemming is not replicated here: a stem-only search
match (say insurance → the OCR word Insured) returns the search hit but
draws no box. Negated terms are never highlighted, and a multi-word phrase
matches only a run of consecutive OCR words in order.
Errors
403 encrypted_not_searchable— the resource is encrypted, or any live owning note is encrypted. Fail-closed: nothing is read or returned.409 ocr_not_ready— the resource’socr_statusis notdone, so there are no word boxes yet.404 not_found— no such resource for this user, or it is tombstoned.422 validation_failed—resource_idmissing, neitherqnortermsyields a usable term, a malformedq, or an invalidpage.
Python
import os
import requests
BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
# Fetch OCR highlight boxes for one attachment (a has_coordinates hit).
resp = requests.get(
f"{BASE}/search/coordinates",
headers=HEADERS,
params={
"resource_id": "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40",
"q": 'budget "q3 plan"',
},
)
if resp.status_code == 409:
print("OCR still running — try again shortly")
raise SystemExit(1)
resp.raise_for_status()
data = resp.json()["data"]
for page in data["pages"]:
for m in page["matches"]:
# `norm` is always present — scale it to your rendered page size.
n = m["norm"]
print(f"page {page['page']}: '{m['word']}' at "
f"x={n['x']:.3f} y={n['y']:.3f} w={n['w']:.3f} h={n['h']:.3f}")
for ph in page["phrases"]:
u = ph["norm_union"]
print(f"page {page['page']}: phrase '{ph['phrase']}' union "
f"x={u['x']:.3f} y={u['y']:.3f}")
if data["truncated"]:
print("output clipped by max_boxes — raise it for more")
JavaScript
const BASE = "https://app.harbor.my/api/v1";
// Fetch OCR highlight boxes for one attachment (a has_coordinates hit).
const params = new URLSearchParams({
resource_id: "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40",
q: 'budget "q3 plan"',
});
const res = await fetch(`${BASE}/search/coordinates?${params}`, {
headers: { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` },
});
if (res.status === 409) throw new Error("OCR still running — try again shortly");
if (!res.ok) throw new Error(`coordinates failed: ${res.status}`);
const { data } = await res.json();
for (const page of data.pages) {
for (const m of page.matches) {
// `norm` is always present — scale it to your rendered page size
// to place the yellow highlight at any zoom level.
const { x, y, w, h } = m.norm;
console.log(`page ${page.page}: "${m.word}" at ${x},${y} (${w}×${h})`);
}
for (const ph of page.phrases) {
console.log(`page ${page.page}: phrase "${ph.phrase}"`, ph.norm_union);
}
}
Go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
const base = "https://app.harbor.my/api/v1"
// rect is a normalized or pixel rectangle from the coordinates payload.
type rect struct {
X, Y, W, H float64
}
// coordPage is one page entry with its word matches and phrase spans.
type coordPage struct {
Page int `json:"page"`
Matches []struct {
Term string `json:"term"`
Word string `json:"word"`
WordIndex int `json:"word_index"`
Norm rect `json:"norm"`
} `json:"matches"`
Phrases []struct {
Phrase string `json:"phrase"`
NormUnion rect `json:"norm_union"`
} `json:"phrases"`
}
func main() {
// Fetch OCR highlight boxes for one attachment (a has_coordinates hit).
params := url.Values{}
params.Set("resource_id", "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40")
params.Set("q", `budget "q3 plan"`)
req, err := http.NewRequest("GET", base+"/search/coordinates?"+params.Encode(), nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("HARBOR_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
fmt.Println("OCR still running — try again shortly")
return
}
var out struct {
Data struct {
Pages []coordPage `json:"pages"`
Truncated bool `json:"truncated"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
for _, page := range out.Data.Pages {
for _, m := range page.Matches {
// Norm is always present — scale it to your rendered page size.
fmt.Printf("page %d: %q at %.3f,%.3f\n", page.Page, m.Word, m.Norm.X, m.Norm.Y)
}
for _, ph := range page.Phrases {
fmt.Printf("page %d: phrase %q union at %.3f,%.3f\n",
page.Page, ph.Phrase, ph.NormUnion.X, ph.NormUnion.Y)
}
}
}
Related
- Files API — fetch attachment bytes by
hash, checkocr_status - Sync API — the pull-only
ocr_textrecord for offline attachment search - Search in Harbor — what this API looks like in the product