Harbor
Developer docs menu

API reference

Search & OCR API

Full-text search across your notes, everything Harbor has read out of your attachments, and their file names — plus the exact on-page coordinates of every match in a photo, scan or PDF, so you can draw the highlights yourself.

Harbor search runs against a per-user FTS5 index, ranked by BM25. File names are the one thing matched outside it, directly against the files. It covers two endpoints: GET /search runs a query and returns ranked note and attachment hits with snippets — an attachment is found by the words inside it (text read from images and PDFs, the contents of Word, Excel, PowerPoint and text files, and recording transcripts and summaries) or by its file name — 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, matched against a note’s title, body, tag names and source URL, and the words inside an attachment and its file name. Multiple free-text terms AND together.
term*A trailing * is a prefix match — recei* matches receive, receipt, … Against a file name the * is dropped and recei matches anywhere in the name.
"exact phrase"Quoted words match consecutively and in order. Against a file name a phrase is one substring, spaces included ("Q3 Budget").
-tokenNegates any token (free-text, phrase, tag:, notebook:, intitle:, meta:). A negated word or phrase also drops a file-name match whose name contains it.
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, so any intitle: term returns no attachment hits.
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.
meta:KEY / meta:KEY=VALUENote has this metadata key, or the key equals the value. See searching by metadata.

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.

Searching by metadata

meta: filters notes by their metadata, the JSON labels your integration puts on them through the API. The Harbor apps don’t advertise it. It’s there for you.

QueryFinds notes where
meta:favorite=truethe top-level key favorite equals true.
meta:album="Summer 2026"album equals Summer 2026. Quote a value with spaces.
meta:albumthe key album exists, whatever its value (null included).
-meta:favorite=trueanything but that.
beach meta:favorite=true“beach” matches and favorite is true.

Several meta: operators AND together. On their own, with no free text, they list every matching note, just like tag:.

The key is split from the value at the first =, and both are trimmed. meta:album= means “equals the empty string”. Keys are case-sensitive; values mostly aren’t. A string value ignores the case of A–Z, true and false match booleans, a number matches the same number, and an array matches if any element does. The full rules are in the matching rule, which the list filters share.

An attachment hit is judged by its note’s metadata, the same as tag:. A file that belongs to no note never matches meta: and is never excluded by -meta:.

A meta: with no key, or a key with anything outside A-Z a-z 0-9 _ -, is a 422 validation_failed:

{
  "error": {
    "code": "validation_failed",
    "message": "The request was invalid.",
    "details": {
      "meta": "expected meta:KEY or meta:KEY=VALUE, where KEY is 1-64 characters of A-Z, a-z, 0-9, '_' or '-'"
    }
  }
}
curl -G "https://app.harbor.my/api/v1/search" \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  --data-urlencode 'q=meta:album="Summer 2026" -meta:favorite=true' \
  --data-urlencode 'types=note'

File names

A free-text query also finds attachments by their file name, extension included. xlsx finds an Excel file and the note it’s in, whatever the words inside it. Names aren’t in the FTS index. They’re matched against the files directly, so a renamed file is found by its new name right away.

  • What matches. A file matches by name when every positive free-text term is somewhere in its file name. A quoted phrase is one term, spaces included. budg* is matched as budg. %, _ and \ match themselves.
  • Case. Only A–Z fold. XLSX finds Q3 Budget.xlsx. Ärzte finds Ärzte Liste.pdf, but ärzte doesn’t. Accents don’t fold either: ubersicht doesn’t find Übersicht.pdf.
  • Negation. -word or -"phrase" drops a name match whose file name contains it or whose words match it.
  • Which files. A name hit has to be a live, unencrypted file that isn’t in any encrypted note, owned by a live note outside the trash. When several notes hold the file, the most recently updated one is the owner. A file in no note, or only in the trash, can still be a text hit, but never a name hit.
  • Filters go by the owning note, the same as for a text hit: tag:, notebook:, meta:, resource: and notebook_id. created: and updated: read the file’s own dates. Any intitle: term returns no files.
  • No positive term, no name match. A query of only operators or only negations (resource:pdf, -xlsx) never matches by name.
  • One hit per file. A file the query matches by the words inside it comes back as a text hit, with its snippet, and never as a name hit too.
  • The hit has match: "name", an empty snippet, highlights: [] and score: 0. Nothing inside the file matched, so show no excerpt and no In file label, and open the owning note when it’s picked.
Q3 Budget.xlsx and…Name match?
xlsx, budget, budget xlsx, budg*✅
"Q3 Budget", "Q3 Budget.xlsx", XLSX✅
budget pdf❌ — pdf isn’t in the name
budget -xlsx❌ — the negated term is in the name
-xlsx, resource:application❌ — no positive free-text term

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": "7d41e0c2-3b8a-4f19-a6d2-5c0e9b7f1a84",
      "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "note_id": "9c2e7b10-4f6d-4a2e-9b1c-3d8e5f7a0c21",
      "note_title": "Quarterly plan",
      "notebook_id": "5b1f2c9a-8e3d-4b7f-a1c6-2f9d0e4b8a53",
      "mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      "filename": "Q3 Budget.xlsx",
      "snippet": "",
      "highlights": [],
      "score": 0,
      "has_coordinates": false,
      "match": "name",
      "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,
      "match": "text",
      "created_at": 1749000000000,
      "updated_at": 1750000000000
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 3, "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, and for a file-name match.
highlightsarrayDeduped, lower-cased matched terms — always an array, never null. Re-highlight without parsing the snippet. [] for a file-name match.
scorenumberBM25 relevance score. 0 for a file-name match.
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. A name hit can be true too, but nothing in the file matched, so there’s nothing to highlight. Always false for a text or Office file: its words are read without word boxes.
matchstringHow the file was found. text: through the index, by the words inside the file — text read from images and PDFs, the contents of Word, Excel, PowerPoint and text files, and recording transcripts and summaries — or by the filters alone in a filters-only query. Harbor’s web, desktop and mobile apps mark a text hit In file. name: by its file name only, with no label. Treat a missing match as text.

Ranking. order=relevance (the default) ranks in fixed tiers, then by BM25 inside each tier:

  1. Notes whose title has every positive term.
  2. Notes clipped from the site you searched: the source host has every positive free-text term.
  3. Notes that match only through their body or tags.
  4. Attachments, always last. File-name matches come first, newest file first, then text matches by score.

Every hit in a tier ranks above every hit in the next one, whatever the raw scores. Inside a tier, note matches score across five weighted columns: title 10.0, body 1.0, tags 4.0, source URL 6.0 and source host 6.0. Ties break by updated_at descending, then id ascending, for stable paging. The score you get back is the raw BM25 value, so it doesn’t show the tier.

A timestamp order is a plain date sort. Notes and files mix by date, and neither the tiers nor the name-first rule apply.

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, a meta: with a bad key), 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']})")
    elif hit.get("match") == "name":
        # Found by its file name: no snippet, nothing to highlight.
        print(f"file name:  {hit['filename']} in \"{hit['note_title']}\"")
    else:
        # Attachment hit — a match on the words 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 if (hit.match === "name") {
    // Found by its file name: no snippet, nothing to highlight.
    console.log(`file name:  ${hit.filename} in "${hit.note_title}"`);
  } 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"`
	Match          string  `json:"match"`
}

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 if hit.Match == "name" {
			// Found by its file name: no snippet, nothing to highlight.
			fmt.Printf("file name:  %s in %q\n", hit.Filename, hit.NoteTitle)
		} 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 text hits (match: "text") from GET /search that have has_coordinates: true.

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. A recording’s transcript and summary sync the same way, as the pull-only transcript record.

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_failed — resource_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