Harbor
Developer docs menu

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:

TokenMeaning
barewordFree-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.
-tokenNegates any token (free-text, phrase, tag:, notebook:, intitle:).
tag:VALUENote 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:VALUENote is in this notebook, matched by exact id first, else case-insensitive name. No match yields no results.
intitle:VALUETerm must appear in the note title. Notes only — attachments have no title.
resource:RTYPENote owns a live, non-encrypted attachment of this type: image, pdf, audio, application, or any.
created:RANGEFilter by creation date (forms below).
updated:RANGEFilter 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:20260615
  • YYYYMMDD..YYYYMMDD — a span, e.g. updated:20260101..20260131
  • day-N — the last N days including today, e.g. updated:day-7

A few semantics worth knowing:

  • Negation always subtracts. report -draft is the report set minus anything also matching draft. A pure-negation query like -spam returns everything except matches — not nothing.
  • Filters-only queries are valid. tag:finance resource:pdf with 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.

FieldTypeRequiredDescription
qstringYesThe query string (grammar above). Empty, whitespace, or unmatchable → 422.
typesstring (CSV)NoHit types to return; each value note or attachment. Default note,attachment.
notebook_idstringNoHard filter to one notebook, ANDed with any notebook: operator in q.
limitintegerNoDefault 100, hard cap 500 (clamped, not errored).
offsetintegerNoDefault 0. Invalid values fall back to 0.
orderstringNorelevance (default), updated_at, -updated_at, created_at, -created_at.
snippetbooleanNoDefault 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:

FieldTypeDescription
typestringnote or attachment.
notebook_idstringThe containing notebook.
snippetstringHTML-escaped excerpt with matched terms wrapped in <em>…</em>. Empty with snippet=false.
highlightsarrayDeduped, lower-cased matched terms — always an array, never null. Re-highlight without parsing the snippet.
scorenumberBM25 relevance score.
created_atintegerUTC epoch milliseconds.
updated_atintegerUTC epoch milliseconds.

Note hits add note_id, title, and tags. Attachment hits add:

FieldTypeDescription
resource_idstringThe attachment’s resource id.
hashstringThe sha256 content address. Fetch the file’s bytes and key highlight overlays by hash, not resource_id — see the Files API.
note_idstringThe owning note.
note_titlestringThe 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.
mimestringThe attachment’s MIME type.
filenamestringThe original filename.
has_coordinatesbooleantrue 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/unmatchable q, a malformed operator value (bad date range, unknown resource: type), an unknown types value, or an unknown order value. details names the offending field.
  • limit > 500 is 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_text record — 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.

FieldTypeRequiredDescription
resource_idstring (UUID)YesThe attachment’s resource id. Missing → 422; unknown, another user’s, or tombstoned → 404.
qstringOne of q/termsA query string (full grammar). Its positive free-text and phrase terms become the highlight targets; operators (tag:, notebook:, dates) are ignored here.
termsstring (CSV)One of q/termsLiteral terms to highlight, e.g. terms=budget,q3. Takes precedence over q.
pageintegerNoRestrict to a single 0-based page index (non-negative; invalid → 422).
max_boxesintegerNoDefault 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:

FieldTypeDescription
resource_idstringThe attachment you asked about.
mimestringThe attachment’s MIME type.
page_countintegerThe attachment’s OCR page count. 0 when no OCR result row exists.
termsarrayThe resolved highlight terms, after folding.
pagesarrayOnly pages that produced hits, ordered ascending.
truncatedbooleantrue 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:

FieldTypeDescription
matches[].termstringThe requested term this word matched.
matches[].wordstringThe actual OCR text on the page.
matches[].word_indexinteger0-based per-page word index.
matches[].boxobjectPixel rectangle {x, y, w, h}. Omitted when page pixel dimensions are unknown.
matches[].normobjectNormalized rectangle, always present, clamped to [0,1]. Multiply by your rendered page size to place the highlight at any zoom.
matches[].confidencenumberOCR confidence for the word.
phrases[].phrasestringThe matched phrase.
phrases[].word_indexesarrayThe contiguous word indexes of the span.
phrases[].boxesarrayPer-word pixel rectangles (omitted when pixel dimensions are unknown).
phrases[].norm_unionobjectOne 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’s ocr_status is not done, so there are no word boxes yet.
  • 404 not_found — no such resource for this user, or it is tombstoned.
  • 422 validation_failedresource_id missing, neither q nor terms yields a usable term, a malformed q, or an invalid page.

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)
		}
	}
}
  • Files API — fetch attachment bytes by hash, check ocr_status
  • Sync API — the pull-only ocr_text record for offline attachment search
  • Search in Harbor — what this API looks like in the product