Harbor
Developer docs menu

API reference

Files & Attachments API

Attachments are content-addressed by sha256 — check, upload, and commit bytes once, then pull them back anywhere with short-lived presigned URLs.

The files API moves attachment bytes in and out of Harbor and manages the metadata around them. Every endpoint on this page requires a bearer token with the files scope.

How files work

Attachment bytes are content-addressed: every blob is stored under a key derived from its sha256. That makes blobs immutable, de-duplicated within your account (the same file attached to five notes is stored once), and never-conflicting. Blobs are strictly per-user — storage keys are namespaced by the owner, so a hash never reaches another user’s bytes.

The hash-first upload flow:

  1. Compute the file’s sha256 locally.
  2. POST /files/check — if exists: true, skip straight to step 5.
  3. POST /files/presign-upload — get a presigned PUT URL.
  4. PUT the raw bytes directly to storage. They never touch the API.
  5. POST /files/commit — create (or return) the resource metadata row.
  6. Embed the file in a note’s body as <harbor-embed resource="sha256:…">.

Clients that can’t presign use POST /files/upload — a multipart pass-through that replaces steps 3–5 (the server computes the sha256 itself).

Linking a file to a note is not done by these endpoints. The server derives the note↔resource link from <harbor-embed resource="sha256:…"> references in the note body on every note write. For encrypted notes the server can’t read the body, so the client extracts the refs from the decrypted body and pushes the link rows itself.

Size and type limits: uploads are capped at 100 MiB per file by default (over the cap → 422 file_too_large); a disallowed MIME type returns 415 unsupported_type.

The resource object

Single resources are returned bare (no data wrapper); only GET /api/v1/files and GET /api/v1/files/stats use a data envelope.

FieldTypeDescription
idstring (UUID)Client UUID — the sync identity of the metadata row.
hashstringThe blob’s sha256 — the identity of the bytes. Write-once.
sizeintByte size. Write-once.
mimestringMIME type. Write-once.
filenamestringStored original name; used to label and name downloads.
is_encryptedbooltrue when the client uploaded ciphertext.
ocr_statusstringpending · processing · done · failed · skipped_encrypted · skipped_unsupported
thumb_statusstringpending · done · skipped · failed · client_encrypted
thumb_small_key, thumb_medium_keystringOpaque store keys of the derived thumbnail variants.
thumb_small_bytes, thumb_medium_bytesintStored byte size of each thumbnail variant.
usnintUpdate sequence number — bumps on every change so sync clients observe it.
deletedboolTombstone flag.
created_at, updated_atintUTC epoch milliseconds.
{
  "id": "0f9c2b1e-7a44-4c8e-9b2d-2a6f1c0d5e3a",
  "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "size": 1048576,
  "mime": "image/png",
  "filename": "diagram.png",
  "is_encrypted": false,
  "ocr_status": "pending",
  "thumb_status": "pending",
  "thumb_small_key": "",
  "thumb_medium_key": "",
  "thumb_small_bytes": 0,
  "thumb_medium_bytes": 0,
  "usn": 4821,
  "deleted": false,
  "created_at": 1718899000000,
  "updated_at": 1718899000000
}

OCR and thumbnails run automatically for non-encrypted images and PDFs — those start at ocr_status: "pending" and advance to a terminal status as the jobs run. Any other non-encrypted MIME is terminal at commit (ocr_status: "skipped_unsupported", thumb_status: "skipped"), so a client polling for a terminal status never waits indefinitely.

Encrypted resources (is_encrypted: true) are opaque to the server: no MIME sniffing, no thumbnail job, no OCR job — ocr_status is skipped_encrypted and thumb_status is skipped. A client may generate its own encrypted thumbnail while unlocked, upload it as a derived ciphertext blob, and set thumb_status to client_encrypted.

Check whether bytes already exist

POST /api/v1/files/check · scope: files

Report whether a blob already exists in your account, checking the resources table first and falling back to a storage HEAD. Call this before uploading — if the bytes are already there, skip straight to commit (or nothing at all).

FieldTypeRequiredDescription
hashstringYes64-hex sha256 of the bytes.
sizeintNoDeclared byte size.
curl https://app.harbor.my/api/v1/files/check \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "size": 1048576}'

Response 200 (bare). When the blob exists, the stored size/mime are echoed back:

{
  "exists": true,
  "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "size": 1048576,
  "mime": "image/png"
}
  • 422 validation_failedhash is not 64 hex characters.

Get a presigned upload URL

POST /api/v1/files/presign-upload · scope: files

Issue a presigned PUT URL for a new blob. The bytes travel from your client directly to storage — they never pass through the API — and the URL pins the Content-Type.

FieldTypeRequiredDescription
hashstringYes64-hex sha256 of the bytes.
sizeintYesByte size. Negative → 422; over the cap → 422 file_too_large.
mimestringYesMIME type. Missing → 422; disallowed → 415.
is_encryptedboolNoSet true when uploading ciphertext.
curl https://app.harbor.my/api/v1/files/presign-upload \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "size": 1048576, "mime": "image/png", "is_encrypted": false}'

Response 200 (bare):

{
  "upload_url": "https://s3.example.com/harbor/blobs/…?X-Amz-Signature=…",
  "method": "PUT",
  "headers": { "Content-Type": "image/png" },
  "expires_at": 1718900000000,
  "key": "blobs/…/e3/b0/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}

Then upload the bytes with the returned method and headers:

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  --data-binary @diagram.png

Treat key as an opaque server-issued string — don’t parse or reconstruct it.

  • 422 validation_failed — bad hash, missing mime, or negative size.
  • 422 file_too_large — declared size over the per-file cap.
  • 415 unsupported_type — MIME not allowed.
  • 409 already_exists — a live resource row already exists; call /commit instead.

Commit an uploaded blob

POST /api/v1/files/commit · scope: files

Register a stored blob by creating (or returning) the resource row. The server HEADs the store, verifies the declared size against the stored size, and upserts the row by hash in a transaction — allocating a usn for a new row. A fresh non-encrypted image/* or application/pdf resource enqueues thumbnail and OCR jobs best-effort (an enqueue failure never fails the commit).

FieldTypeRequiredDescription
hashstringYes64-hex sha256 of the bytes.
sizeintYesByte size — must match the stored size.
mimestringYesMIME type.
filenamestringNoOriginal filename.
is_encryptedboolNoSet true for ciphertext.
curl https://app.harbor.my/api/v1/files/commit \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "size": 1048576, "mime": "image/png", "filename": "diagram.png", "is_encrypted": false}'

Response: 201 when created, 200 when the row already existed — commit is idempotent and retry-safe. The body is the bare resource object:

{
  "id": "0f9c2b1e-7a44-4c8e-9b2d-2a6f1c0d5e3a",
  "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "size": 1048576,
  "mime": "image/png",
  "filename": "diagram.png",
  "is_encrypted": false,
  "ocr_status": "pending",
  "thumb_status": "pending",
  "usn": 4821,
  "deleted": false,
  "created_at": 1718899000000,
  "updated_at": 1718899000000
}
  • 404 blob_missing — the bytes aren’t in the store; presign and PUT first.
  • 422 validation_failed — declared size doesn’t match the stored size.

Upload end-to-end

The full hash-first flow — check, presign, PUT, commit — in each language.

Python

import hashlib
import os

import requests

BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}

with open("diagram.png", "rb") as f:
    data = f.read()
sha = hashlib.sha256(data).hexdigest()

# 1. Skip the upload if Harbor already has these bytes
check = requests.post(f"{BASE}/files/check", headers=HEADERS,
                      json={"hash": sha, "size": len(data)}).json()

if not check["exists"]:
    # 2. Presign
    presign = requests.post(f"{BASE}/files/presign-upload", headers=HEADERS,
                            json={"hash": sha, "size": len(data),
                                  "mime": "image/png"}).json()
    # 3. PUT the bytes directly to storage (no auth header — the URL is signed)
    requests.put(presign["upload_url"], data=data,
                 headers=presign["headers"]).raise_for_status()

# 4. Commit — creates or returns the resource row
resource = requests.post(f"{BASE}/files/commit", headers=HEADERS,
                         json={"hash": sha, "size": len(data),
                               "mime": "image/png",
                               "filename": "diagram.png"}).json()
print(resource["id"], resource["ocr_status"])

JavaScript

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";

const BASE = "https://app.harbor.my/api/v1";
const AUTH = { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` };
const JSON_HEADERS = { ...AUTH, "Content-Type": "application/json" };

const bytes = await readFile("diagram.png");
const hash = createHash("sha256").update(bytes).digest("hex");

// 1. Skip the upload if Harbor already has these bytes
const check = await fetch(`${BASE}/files/check`, {
  method: "POST",
  headers: JSON_HEADERS,
  body: JSON.stringify({ hash, size: bytes.length }),
}).then((r) => r.json());

if (!check.exists) {
  // 2. Presign
  const presign = await fetch(`${BASE}/files/presign-upload`, {
    method: "POST",
    headers: JSON_HEADERS,
    body: JSON.stringify({ hash, size: bytes.length, mime: "image/png" }),
  }).then((r) => r.json());

  // 3. PUT the bytes directly to storage
  await fetch(presign.upload_url, {
    method: "PUT",
    headers: presign.headers,
    body: bytes,
  });
}

// 4. Commit — creates or returns the resource row
const resource = await fetch(`${BASE}/files/commit`, {
  method: "POST",
  headers: JSON_HEADERS,
  body: JSON.stringify({
    hash,
    size: bytes.length,
    mime: "image/png",
    filename: "diagram.png",
  }),
}).then((r) => r.json());
console.log(resource.id, resource.ocr_status);

Go

package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const base = "https://app.harbor.my/api/v1"

// call sends an authenticated JSON request and decodes the JSON response.
func call(method, url string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		if err := json.NewEncoder(&buf).Encode(body); err != nil {
			return err
		}
	}
	req, err := http.NewRequest(method, url, &buf)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("HARBOR_TOKEN"))
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return json.NewDecoder(resp.Body).Decode(out)
}

func main() {
	data, err := os.ReadFile("diagram.png")
	if err != nil {
		panic(err)
	}
	sum := sha256.Sum256(data)
	hash := hex.EncodeToString(sum[:])

	// 1. Skip the upload if Harbor already has these bytes.
	var check struct {
		Exists bool `json:"exists"`
	}
	if err := call("POST", base+"/files/check",
		map[string]any{"hash": hash, "size": len(data)}, &check); err != nil {
		panic(err)
	}

	if !check.Exists {
		// 2. Presign.
		var presign struct {
			UploadURL string            `json:"upload_url"`
			Headers   map[string]string `json:"headers"`
		}
		if err := call("POST", base+"/files/presign-upload",
			map[string]any{"hash": hash, "size": len(data), "mime": "image/png"},
			&presign); err != nil {
			panic(err)
		}

		// 3. PUT the bytes directly to storage.
		req, _ := http.NewRequest("PUT", presign.UploadURL, bytes.NewReader(data))
		for k, v := range presign.Headers {
			req.Header.Set(k, v)
		}
		if _, err := http.DefaultClient.Do(req); err != nil {
			panic(err)
		}
	}

	// 4. Commit — creates or returns the resource row.
	var resource struct {
		ID        string `json:"id"`
		OCRStatus string `json:"ocr_status"`
	}
	if err := call("POST", base+"/files/commit", map[string]any{
		"hash": hash, "size": len(data),
		"mime": "image/png", "filename": "diagram.png",
	}, &resource); err != nil {
		panic(err)
	}
	fmt.Println(resource.ID, resource.OCRStatus)
}

Upload directly (multipart)

POST /api/v1/files/upload · scope: files

A multipart pass-through for clients that can’t presign. The bytes flow through the API, the sha256 is computed server-side, and the resource row is upserted with derived jobs enqueued exactly like /commit. Prefer the hash-first flow when you can — it skips uploads Harbor already has and keeps bytes off the API.

FieldTypeRequiredDescription
filefile partYesThe bytes.
mimestringNoMIME type.
filenamestringNoOriginal filename.
is_encryptedboolNoSet true for ciphertext.
curl https://app.harbor.my/api/v1/files/upload \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -F "file=@diagram.png;type=image/png" \
  -F "filename=diagram.png"

Response: the bare resource object — 201 when created, 200 when the blob/row already existed.

  • 422 validation_failed — no file field.
  • 422 file_too_large — the size cap is enforced while streaming.
  • 415 unsupported_type — MIME not allowed.

Get a download URL

GET /api/v1/files/:hash · scope: files

Download via a presigned GET URL — for the original or a thumbnail variant. The resource row must exist in your account; a hash never resolves to another user’s bytes.

FieldTypeRequiredDescription
:hashstring (path)YesThe blob’s sha256.
redirectintNoredirect=1 returns 302 Found with Location set to the presigned URL — handy for <img src>.
dispositionstringNodisposition=attachment forces Content-Disposition: attachment; filename="…" on the presigned URL so a browser saves instead of rendering. Use for an explicit “Download”; omit for inline preview. Composes with redirect=1.
variantstringNosmall or medium — presigns the derived thumbnail blob instead of the original. 404 when that variant has no thumbnail.
curl "https://app.harbor.my/api/v1/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response 200 (bare):

{
  "download_url": "https://s3.example.com/harbor/blobs/…?X-Amz-Signature=…",
  "expires_at": 1718902600000,
  "mime": "image/png",
  "size": 1048576,
  "filename": "diagram.png"
}

filename is the stored original name — use it to label the download. It’s omitted for thumbnail variants. With ?redirect=1 you get a 302 instead of the JSON body.

Thumbnail variants are available when a key is recorded and thumb_status is done — or client_encrypted for an encrypted resource, in which case the presigned bytes are ciphertext your client decrypts. The variant’s mime is the stored object’s MIME (image/jpeg for opaque sources, image/png for transparent ones, or the original’s MIME when the thumbnail key aliases the original blob). Otherwise the variant request returns 404, so fall back to a generic icon or the full render:

curl "https://app.harbor.my/api/v1/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855?variant=small&redirect=1" \
  -H "Authorization: Bearer $HARBOR_TOKEN"
  • 404 not_found — no resource row for this user, bytes missing in the store, or the requested variant has no thumbnail.

Download in each language

Fetch the presigned URL, then get the bytes — no auth header on the presigned request; the URL itself is signed and short-lived.

Python

import os

import requests

BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}

sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
meta = requests.get(f"{BASE}/files/{sha}", headers=HEADERS).json()

blob = requests.get(meta["download_url"])
blob.raise_for_status()
with open(meta["filename"], "wb") as f:
    f.write(blob.content)

JavaScript

import { writeFile } from "node:fs/promises";

const BASE = "https://app.harbor.my/api/v1";
const AUTH = { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` };

const sha =
  "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const meta = await fetch(`${BASE}/files/${sha}`, { headers: AUTH }).then((r) =>
  r.json(),
);

const blob = await fetch(meta.download_url);
await writeFile(meta.filename, Buffer.from(await blob.arrayBuffer()));

Go

package main

import (
	"encoding/json"
	"io"
	"net/http"
	"os"
)

const base = "https://app.harbor.my/api/v1"

func main() {
	sha := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

	// Fetch the presigned download URL and metadata.
	req, _ := http.NewRequest("GET", base+"/files/"+sha, nil)
	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 meta struct {
		DownloadURL string `json:"download_url"`
		Filename    string `json:"filename"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
		panic(err)
	}

	// Fetch the bytes — the presigned URL needs no auth header.
	blob, err := http.Get(meta.DownloadURL)
	if err != nil {
		panic(err)
	}
	defer blob.Body.Close()

	out, err := os.Create(meta.Filename)
	if err != nil {
		panic(err)
	}
	defer out.Close()
	if _, err := io.Copy(out, blob.Body); err != nil {
		panic(err)
	}
}

Stream the raw bytes

GET /api/v1/files/:hash/raw · scope: files

Stream the blob straight through the API with the stored Content-Type/Content-Length and Cache-Control: private, immutable — content-addressed bytes are safe to cache forever. Use this when presigned URLs can’t be followed (CORS, embedded contexts); it costs API bandwidth, so prefer the presigned path when you can. Same per-user authorization as the presigned download.

curl "https://app.harbor.my/api/v1/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/raw" \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -o diagram.png

Response: 200 with the raw bytes.

  • 404 not_found — no resource row for this user or bytes missing.

List files

GET /api/v1/files · scope: files

List your files, each with its linked note(s). Pagination is over distinct files — a file linked to N notes is one row carrying N notes, and counts once toward total.

FieldTypeRequiredDescription
limitintNoDefault 100, hard cap 500 (clamped, not errored).
offsetintNoDefault 0.
orderstringNoDefault -updated_at. Sortable: updated_at, created_at, size, mime (- prefix = descending).
q (alias filename)stringNoCase-insensitive substring match on filename. LIKE wildcards match literally; q wins if both are sent.
mimestringNoExact (image/png) or type/ prefix (image/ matches all images).
note_idstring (UUID)NoOnly files with a live link to this note.
ocr_statusstringNopending · processing · done · failed · skipped_encrypted · skipped_unsupported
is_encryptedboolNotrue / false.
updated_sinceint (epoch ms)NoFiles with updated_at >= the value.
curl "https://app.harbor.my/api/v1/files?mime=image/&limit=2" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response 200 — the standard collection envelope:

{
  "data": [
    {
      "id": "0f9c2b1e-7a44-4c8e-9b2d-2a6f1c0d5e3a",
      "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
      "size": 1048576,
      "mime": "image/png",
      "filename": "diagram.png",
      "is_encrypted": false,
      "ocr_status": "done",
      "thumb_status": "done",
      "thumb_small_key": "blobs/…/e3/b0/e3b0…_thumb_s",
      "thumb_medium_key": "blobs/…/e3/b0/e3b0…_thumb_m",
      "usn": 4821,
      "created_at": 1718899000000,
      "updated_at": 1718899500000,
      "notes": [
        {
          "note_id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
          "title": "Architecture sketch",
          "role": "inline",
          "position": null,
          "is_encrypted": false
        }
      ]
    }
  ],
  "paging": { "limit": 2, "offset": 0, "total": 37, "has_more": true }
}

Notes on the shape:

  • notes is always present, never null — an orphaned file returns []. Links to tombstoned notes are excluded.
  • When a linked note has is_encrypted: true, its title is an opaque encrypted envelope — clients decrypt it while unlocked.
  • position is currently always null — it’s reserved; don’t rely on it to order embeds.
  • Download and thumbnail URLs are not inlined (they’re presigned and short-lived). Fetch the original via GET /files/{hash} and thumbnails via GET /files/{hash}?variant=small|medium.

Errors:

  • 422 validation_failed — bad ocr_status, non-UUID note_id, non-numeric updated_since, or an unknown sort key. limit > 500 is clamped, not errored.

Get storage totals

GET /api/v1/files/stats · scope: files

Blob totals for your account — the summed byte size and count of live files, aggregated in SQL. It honors the same filters as the listing (q / filename, mime, note_id, ocr_status, is_encrypted, updated_since), so a filtered total matches the filtered list — drive both from the same query string. Pagination params (limit/offset/order) are ignored.

curl "https://app.harbor.my/api/v1/files/stats?mime=image/" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response 200:

{ "data": { "total_size": 5242880, "total_count": 12 } }

total_size is in bytes. Both fields are 0 — never null — when nothing matches.

  • 422 validation_failed — same filter validation as the listing.

Audio transcription

Audio attachments can be transcribed on request — nothing is transcribed automatically at upload, since the cloud engine bills per audio-hour. The pipeline mirrors OCR: a background job runs the configured engine, stores the result, stamps transcription_status on the resource (none · pending · processing · done · failed · skipped_encrypted · skipped_unsupported — every change bumps the resource’s usn so clients observe it), and feeds the transcript and summary text into full-text search.

Worth knowing before you build:

  • Diarized. Segments carry the engine’s raw speaker labels (A, B, …). Render them as Speaker 1/2/3 unless renamed via the per-transcript speaker-name map — renames apply instantly, with no re-transcription.
  • Clean text. Filler words (“um”, “uh”) are excluded.
  • Summaries are generated over the completed transcript — never raw audio — stored once, and re-runnable.
  • Encrypted audio is never transcribed or summarized (skipped_encrypted) — ciphertext never leaves for the cloud.
  • When no transcription engine is configured, the feature is unavailable — responses report available: false, and clients hide the actions.
  • The transcript itself is served over REST and does not sync in v1.

:hash on these routes is the attachment’s sha256 content hash — the same key the blob uses. Single objects are returned bare.

Get a transcript

GET /api/v1/files/:hash/transcript · scope: files

The transcript state for one attachment. Poll this while a transcription run (or a summary) is in flight.

curl "https://app.harbor.my/api/v1/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/transcript" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response 200 (bare):

{
  "available": true,
  "status": "done",
  "transcript": {
    "id": "5a3c8e2f-1b4d-4f6a-9c8e-7d2b5a1f0e3c",
    "engine": "assemblyai",
    "language": "en_us",
    "audio_duration_ms": 281000,
    "speaker_names": { "A": "Spicer" },
    "segments": [
      { "speaker": "A", "text": "Hello there.", "start_ms": 250, "end_ms": 1900 },
      { "speaker": "B", "text": "General Kenobi.", "start_ms": 2100, "end_ms": 3800 }
    ],
    "summary": {
      "status": "done",
      "text": "• Introductions exchanged…",
      "model": "claude-sonnet-4-6",
      "generated_at": 1752300000000
    },
    "updated_at": 1752300000000
  }
}
  • available — whether a transcription engine is configured at all.
  • status — the resource’s transcription_status.
  • transcript — present only once a run has completed; it survives later failed re-runs.
  • segments[].speaker — the engine’s raw label. Resolve display names via speaker_names, falling back to Speaker N by order of first appearance.
  • summary.statusnone · processing · done · failed.

Errors:

  • 422 validation_failed — malformed hash.
  • 404 not_found — no such attachment.

Request transcription

POST /api/v1/files/:hash/transcript · scope: files

Request transcription — the Transcribe action. Stamps transcription_status: "pending" and enqueues the background job. A repeat call while pending/processing is a cheap no-op; a call after done/failed re-runs and replaces the stored transcript (segments are never duplicated, the speaker-name map survives, and a stored summary is cleared as stale).

curl -X POST "https://app.harbor.my/api/v1/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/transcript" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response 200:

{ "available": true, "status": "pending" }

Errors:

  • 501 transcription_unavailable — no engine configured.
  • 403 transcription_encrypted — encrypted audio is never sent to the cloud.
  • 415 transcription_unsupported_media — not a supported audio type.
  • 404 not_found — no such attachment.

Rename speakers

PUT /api/v1/files/:hash/transcript/speakers · scope: files

Store the speaker-name map. Rename a raw label (e.g. Speaker 2) once and every segment for that speaker re-labels everywhere — no re-transcription. The map is replaced wholesale; a blank or absent name clears the custom name for that label (clients fall back to Speaker N).

FieldTypeRequiredDescription
namesobjectYesMap of raw label → display name. Replaces the whole map.
curl -X PUT "https://app.harbor.my/api/v1/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/transcript/speakers" \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"names": {"A": "Spicer", "B": "George"}}'

Response 200:

{ "speaker_names": { "A": "Spicer", "B": "George" } }

Errors:

  • 409 transcript_not_ready — no transcript yet.
  • 404 not_found — no such attachment.

Summarize a recording

POST /api/v1/files/:hash/transcript/summary · scope: files

Request the on-request Summarize this recording pass over the completed transcript — a bullet summary plus action items via the engine’s LLM gateway. The stored summary is replaced on a re-run. Poll GET /files/:hash/transcript to watch summary.status move to done.

curl -X POST "https://app.harbor.my/api/v1/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/transcript/summary" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response 200:

{ "summary_status": "processing" }

Errors:

  • 501 transcription_unavailable — no engine configured.
  • 403 transcription_encrypted — encrypted audio.
  • 409 transcript_not_ready — transcription isn’t done yet.
  • 404 not_found — no such attachment.
  • Notes API — embed a committed file in a note body with <harbor-embed resource="sha256:…">.
  • Search API — OCR text, transcripts, and summaries all feed full-text search.
  • Errors — the error envelope and shared status codes.