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:
- Compute the file’s
sha256locally. POST /files/check— ifexists: true, skip straight to step 5.POST /files/presign-upload— get a presigned PUT URL.PUTthe raw bytes directly to storage. They never touch the API.POST /files/commit— create (or return) the resource metadata row.- 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.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Client UUID — the sync identity of the metadata row. |
hash | string | The blob’s sha256 — the identity of the bytes. Write-once. |
size | int | Byte size. Write-once. |
mime | string | MIME type. Write-once. |
filename | string | Stored original name; used to label and name downloads. |
is_encrypted | bool | true when the client uploaded ciphertext. |
ocr_status | string | pending · processing · done · failed · skipped_encrypted · skipped_unsupported |
thumb_status | string | pending · done · skipped · failed · client_encrypted |
thumb_small_key, thumb_medium_key | string | Opaque store keys of the derived thumbnail variants. |
thumb_small_bytes, thumb_medium_bytes | int | Stored byte size of each thumbnail variant. |
usn | int | Update sequence number — bumps on every change so sync clients observe it. |
deleted | bool | Tombstone flag. |
created_at, updated_at | int | UTC 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).
| Field | Type | Required | Description |
|---|---|---|---|
hash | string | Yes | 64-hex sha256 of the bytes. |
size | int | No | Declared 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_failed—hashis 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.
| Field | Type | Required | Description |
|---|---|---|---|
hash | string | Yes | 64-hex sha256 of the bytes. |
size | int | Yes | Byte size. Negative → 422; over the cap → 422 file_too_large. |
mime | string | Yes | MIME type. Missing → 422; disallowed → 415. |
is_encrypted | bool | No | Set 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/commitinstead.
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).
| Field | Type | Required | Description |
|---|---|---|---|
hash | string | Yes | 64-hex sha256 of the bytes. |
size | int | Yes | Byte size — must match the stored size. |
mime | string | Yes | MIME type. |
filename | string | No | Original filename. |
is_encrypted | bool | No | Set 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— declaredsizedoesn’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.
| Field | Type | Required | Description |
|---|---|---|---|
file | file part | Yes | The bytes. |
mime | string | No | MIME type. |
filename | string | No | Original filename. |
is_encrypted | bool | No | Set 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— nofilefield.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.
| Field | Type | Required | Description |
|---|---|---|---|
:hash | string (path) | Yes | The blob’s sha256. |
redirect | int | No | redirect=1 returns 302 Found with Location set to the presigned URL — handy for <img src>. |
disposition | string | No | disposition=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. |
variant | string | No | small 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 requestedvarianthas 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.
| Field | Type | Required | Description |
|---|---|---|---|
limit | int | No | Default 100, hard cap 500 (clamped, not errored). |
offset | int | No | Default 0. |
order | string | No | Default -updated_at. Sortable: updated_at, created_at, size, mime (- prefix = descending). |
q (alias filename) | string | No | Case-insensitive substring match on filename. LIKE wildcards match literally; q wins if both are sent. |
mime | string | No | Exact (image/png) or type/ prefix (image/ matches all images). |
note_id | string (UUID) | No | Only files with a live link to this note. |
ocr_status | string | No | pending · processing · done · failed · skipped_encrypted · skipped_unsupported |
is_encrypted | bool | No | true / false. |
updated_since | int (epoch ms) | No | Files 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:
notesis always present, nevernull— an orphaned file returns[]. Links to tombstoned notes are excluded.- When a linked note has
is_encrypted: true, itstitleis an opaque encrypted envelope — clients decrypt it while unlocked. positionis currently alwaysnull— 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 viaGET /files/{hash}?variant=small|medium.
Errors:
422 validation_failed— badocr_status, non-UUIDnote_id, non-numericupdated_since, or an unknown sort key.limit > 500is 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’stranscription_status.transcript— present only once a run has completed; it survives laterfailedre-runs.segments[].speaker— the engine’s raw label. Resolve display names viaspeaker_names, falling back toSpeaker Nby order of first appearance.summary.status—none·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).
| Field | Type | Required | Description |
|---|---|---|---|
names | object | Yes | Map 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’tdoneyet.404 not_found— no such attachment.
Related
- 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.