API reference
Import & Export API
Import an Evernote .enex file or a zipped Obsidian vault as a pollable job, and export any notebook or note selection back to a valid, round-trippable ENEX document.
This is Harbor’s Evernote-portability surface: upload a .enex file and Harbor
imports every note in it — content, tags, attachments, web clips, even Evernote
Tasks — as a job you can poll; or export a notebook (or a hand-picked set of
notes) back out as a valid <en-export> document. If you’re moving from
Evernote, the switch guide walks through the whole migration — this
page documents the API underneath it.
All endpoints require the notes scope.
Responses are {"data": ...}-wrapped, with one exception: the ENEX export
streams the raw file.
What an import does
Each <note> in the file becomes a Harbor note, and the import behaves like
any other write — imported notes are indexed for search, get history
snapshots, and receive fresh USNs so they flow through sync
to every device. Specifically:
- Content — ENML is converted to Harbor’s sanitized HTML (XSS neutralized,
search plaintext derived). Repeated
<tag>elements become tags, deduped by case-insensitive name. - Attachments — each base64
<resource>is decoded, content-addressed by sha256, and deduped. Evernote references resources by MD5 (<en-media hash="...">); on import each reference is rewritten to<harbor-embed resource="sha256:...">pointing at the stored blob, so images and PDFs render inline. Images and PDFs are queued for thumbnails and OCR, exactly like an uploaded file. - Web clips — a note stamped with a
sourceattribute likeweb.clip(or, for marker-less exports, asource-urlplus deep<div>nesting) is flaggedis_web_clipand sanitized with a layout-preserving allowlist, so the page snapshot survives. - Evernote Tasks — note-level
<task>elements import as first-class Harbor tasks linked to the note. Task ids are deterministic per note, so re-delivering the same file never duplicates tasks. (ENEX export does not yet emit tasks back.) - Encryption — the server holds no keys, so it cannot encrypt plaintext on the way in: importing into an encrypted notebook is rejected. That is the same rule a client-side move obeys, reaching the opposite outcome for the same reason: a client moving a note into an encrypting notebook can seal it first because it holds the key, and a server-side import cannot, so it refuses rather than storing plaintext where plaintext isn’t allowed. On export, encrypted notes are skipped and reported.
A bad individual note is recorded and skipped — it never aborts the rest of the import.
The import job object
Every import is tracked as a job. The create call returns the id as
import_job_id; status reads return it as id.
| Field | Type | Description |
|---|---|---|
id | string | Job id (import_job_id in create responses). |
kind | string | Which importer this job belongs to — enex here. List import jobs returns every format’s jobs whatever path you polled, so read this rather than assuming. |
filename | string | The name you declared when creating the upload. |
total_bytes | int | The size you declared, in bytes. |
created_at | int | When the job was created, UTC epoch milliseconds. |
status | string | One of awaiting_upload, queued, running, completed, partial, failed, aborted. |
total_notes | int | Notes in the file. The worker pre-counts <note> elements before importing, so this is usually accurate from the start — but the pre-count is skipped when it fails, and a cut-off file leaves it 0. Never divide by it without checking. |
imported_notes | int | Notes imported so far — climbs live while running. |
skipped_notes | int | Notes skipped. |
failed_notes | int | Notes that failed to import (see errors). |
failure_reason | string | Why a job could not finish cleanly. Always present, and empty far more often than not: it is set only when the file itself was the problem, which can happen on partial as well as failed — see below. |
errors | array | Up to 100 per-note failures, each {note_index, title, reason}. A terminal job-level failure is a single entry with note_index: -1. Always present on status reads, never null. |
updated_at | int | Last progress persist, UTC epoch milliseconds. |
{
"id": "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f",
"kind": "enex",
"status": "running",
"filename": "Contractors.enex",
"total_bytes": 1073741824,
"created_at": 1784131100000,
"total_notes": 120,
"imported_notes": 38,
"skipped_notes": 0,
"failed_notes": 0,
"failure_reason": "",
"errors": [],
"updated_at": 1784131200000
}
Lifecycle. A job starts at awaiting_upload and stays there until you
complete the upload; it then runs through queued and running to one
terminal state:
completed— every note imported.partial— some notes imported and some did not. Either the whole file was read and individual notes failed, or the file was cut off part-way with at least one note already in. Checkerrorsandfailure_reason.failed— a job-level failure, and where a cut-off transfer (incomplete_read) always lands, however many notes had been read: the rest of the file is still in the stored object, so calling itpartialwould imply those notes were lost when they weren’t. A cut-off export file (truncated_source) is the other case, and it lands onpartialwhen at least one note made it in.aborted— an upload was cancelled.
A bad file surfaces here, not as an HTTP error. Nothing you send to the
upload endpoints inspects the ENEX — the file is only read once the import
runs, so an upload that returned 202 can still fail a minute later.
failure_reason carries the answer. It can be set on partial as well as
failed, so read it whenever a job doesn’t come back completed:
failure_reason | Means | Worth retrying? |
|---|---|---|
not_enex | Not an <en-export> document. | No — re-export from Evernote. |
truncated_source | Every uploaded byte was read and the document still ended mid-note: the export itself is incomplete. Lands on partial when some notes made it in, failed when none did. | No — the missing notes aren’t in the file. |
incomplete_read | The byte stream ended before the object’s known size — a cut-off transfer. | Start a fresh upload. The stored object is kept, but only Harbor can resume from it. |
upload_incomplete | Completing assembled fewer bytes than you declared: not every part was uploaded. Also returned synchronously as 422 import_upload_incomplete. | Start a fresh upload — the job is finished and won’t accept more parts. |
source_missing | The staged upload was gone by the time the import ran — swept, or never completed. | No — start a new upload. |
notebook_unavailable | target_notebook_id went away before the import ran. | Yes, with a different notebook. |
unknown | A failure Harbor couldn’t classify. | Worth one retry. |
Individual notes fail separately, in errors[]. On an ENEX import a note that
fails without taking the job down reports note_malformed,
attachment_unreadable, storage_unavailable, or unknown for anything
Harbor couldn’t classify. Treat an unrecognized code as unknown — the set
can grow, and the API already normalizes anything it doesn’t recognize to that
before it reaches you.
None of them can arrive on a completed job — a job with any failed note is
partial at best. file_truncated is further apart still: it marks the note
the file was cut off in the middle of, so the rest of the file is gone rather
than skipped.
⚠️ Check imported_notes, not just status. A file that isn’t XML at all
currently finishes as completed with total_notes: 0 rather than
failed — there is nothing to parse, so nothing reports a parse failure. A
job that imported zero notes is worth surfacing to your user whatever its
status says.
Start an ENEX import
Importing is an upload straight to object storage, then four small metadata
calls to the API. The .enex bytes never travel through the API at all — a
background worker stream-parses one <note> at a time out of storage, so a
40 GB Evernote export costs the same API memory as a 40 KB one.
There is no single-request upload endpoint. POST /api/v1/import/enex used to
buffer the whole file server-side and now returns 404; the flow below
replaced it.
- Create the upload — declare the file size, get a chunking plan back.
- Presign parts — get
PUTURLs for a batch of part numbers. PUTeach chunk to its URL, keeping theETagfrom every response. Read the file inpart_sizeslices rather than loading it into memory.- Complete — hand back the ETags. The import is enqueued and you poll it like any other job.
Abort cancels an upload you no longer want to finish.
Two limits, both enforced server-side: an import is capped at 100 GiB, and a single presign call takes at most 1000 part numbers — ask for them in batches as you go rather than all at once.
The staged file lives at a transient cache key. It is deleted once the import
finishes; a failed job keeps it for a while, so a cut-off transfer can be
replayed. A rejected complete is the exception — that object goes at once.
If you host Harbor yourself, the bucket must allow
cross-origin PUT and expose the ETag response header (ExposeHeaders: ETag), or the client cannot complete the upload.
202 means queued, never valid. Nothing you send to these four endpoints
inspects the ENEX — see
what a bad file looks like.
Create a direct upload
POST /api/v1/import/enex/uploads · scope: notes
Creates the job in status awaiting_upload and returns the chunking plan.
| Field | Type | Required | Description |
|---|---|---|---|
total_bytes | int | yes | The .enex size in bytes. Must be greater than 0 and within the 100 GiB cap. |
filename | string | no | Names the auto-created notebook: Contractors.enex becomes a notebook called Contractors, created once and reused across retries. Falls back to Imported Notes. |
target_notebook_id | string | no | Must exist and must not be encrypted. |
notify_email | boolean | no | Email you when the import finishes. Defaults on — omit it and you get the mail; send false to opt out. Persisted on the job, since the import runs later on complete. |
curl https://app.harbor.my/api/v1/import/enex/uploads \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"total_bytes": 1073741824, "filename": "Contractors.enex"}'
Response — 201 Created:
{
"data": {
"import_job_id": "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f",
"status": "awaiting_upload",
"part_size": 67108864,
"part_count": 16
}
}
Slice the file into part_count parts of part_size bytes each (the last
part is the remainder); part numbers are 1-based.
Errors: 422 enex_too_large, 422 validation_failed (non-positive
total_bytes), 404 not_found / 422 cannot_import_into_encrypted (bad
target_notebook_id).
Presign part URLs
POST /api/v1/import/enex/uploads/:job_id/parts · scope: notes
Presign a batch of part URLs — at most 1000 per call; request them
incrementally for a huge upload. PUT each chunk to its URL and read the ETag
header from the PUT response.
| Field | Type | Required | Description |
|---|---|---|---|
part_numbers | int[] | yes | Part numbers to presign (1-based). |
curl https://app.harbor.my/api/v1/import/enex/uploads/0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f/parts \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"part_numbers": [1, 2, 3]}'
Response — 200 OK:
{
"data": {
"parts": [
{ "part_number": 1, "url": "https://storage.example.com/cache/imports/..." }
],
"expires_in_seconds": 21600
}
}
Errors: 404 not_found (unknown job), 422 validation_failed (empty list,
over the 1000-part batch cap, or a part number outside 1..part_count).
Complete a direct upload
POST /api/v1/import/enex/uploads/:job_id/complete · scope: notes
Finalize the upload from its parts (any order) and enqueue the import.
Returns 202 with the job to poll.
| Field | Type | Required | Description |
|---|---|---|---|
parts | array | yes | One {part_number, etag} per uploaded part. |
curl https://app.harbor.my/api/v1/import/enex/uploads/0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f/complete \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"parts": [{"part_number": 1, "etag": "9b2cf535f27731c974343645a3985328"}]}'
Response — 202 Accepted:
{
"data": {
"import_job_id": "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f",
"status": "queued",
"total_notes": 0,
"imported_notes": 0,
"skipped_notes": 0,
"failed_notes": 0
}
}
Errors:
404 not_found— unknown job.422 validation_failed— emptyparts, or the job is no longer awaiting an upload.422 import_upload_incomplete— integrity check: the assembled object must be exactly the declaredtotal_bytes. If it’s smaller, the short object is deleted, the job is markedfailed, and no import runs — re-upload the file.
Abort a direct upload
POST /api/v1/import/enex/uploads/:job_id/abort · scope: notes
Cancel an in-flight direct upload: aborts the multipart upload (no orphaned
parts in storage), reclaims any staged object, and marks the job aborted.
curl -X POST https://app.harbor.my/api/v1/import/enex/uploads/0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f/abort \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response — 200 OK: the job’s status body, with status: "aborted".
Errors: 404 not_found, 422 validation_failed (the job is not awaiting an
upload).
A complete import, end to end
Create → presign → PUT each chunk keeping its ETag → complete → poll. Every
example reads the file in part_size slices; none of them loads it into memory.
curl
BASE=https://app.harbor.my/api/v1
FILE=Contractors.enex
BYTES=$(wc -c < "$FILE")
# 1. Create — returns the job id and the chunking plan.
CREATED=$(curl -s "$BASE/import/enex/uploads" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"total_bytes\": $BYTES, \"filename\": \"$FILE\"}")
JOB=$(echo "$CREATED" | jq -r .data.import_job_id)
PART_SIZE=$(echo "$CREATED" | jq -r .data.part_size)
PART_COUNT=$(echo "$CREATED" | jq -r .data.part_count)
# 2 & 3. Presign in batches of 1000 (the server's per-call maximum), then PUT
# each slice, keeping the ETag the storage returns.
ETAGS="[]"
FROM=1
while [ "$FROM" -le "$PART_COUNT" ]; do
TO=$(( FROM + 999 )); [ "$TO" -gt "$PART_COUNT" ] && TO=$PART_COUNT
NUMBERS=$(seq "$FROM" "$TO" | jq -s .)
URLS=$(curl -s "$BASE/import/enex/uploads/$JOB/parts" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"part_numbers\": $NUMBERS}" | jq -c '.data.parts[]')
for P in $URLS; do
N=$(echo "$P" | jq -r .part_number)
URL=$(echo "$P" | jq -r .url)
dd if="$FILE" bs="$PART_SIZE" skip=$((N - 1)) count=1 2>/dev/null > part.bin
ETAG=$(curl -s -X PUT --data-binary @part.bin "$URL" -D - -o /dev/null \
| tr -d '\r' | sed -n 's/^[Ee][Tt]ag: *//p' | tr -d '"')
ETAGS=$(echo "$ETAGS" | jq -c ". + [{\"part_number\": $N, \"etag\": \"$ETAG\"}]")
done
FROM=$(( TO + 1 ))
done
rm -f part.bin
# 4. Complete, then poll.
curl -s "$BASE/import/enex/uploads/$JOB/complete" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"parts\": $ETAGS}" > /dev/null
curl -s "$BASE/import/enex/$JOB" -H "Authorization: Bearer $HARBOR_TOKEN" | jq .data
Python
import os, time, requests
BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
PATH = "Contractors.enex"
created = requests.post(
f"{BASE}/import/enex/uploads",
headers=HEADERS,
json={"total_bytes": os.path.getsize(PATH), "filename": os.path.basename(PATH)},
).json()["data"]
job, part_size, part_count = (
created["import_job_id"], created["part_size"], created["part_count"]
)
etags = []
with open(PATH, "rb") as fh:
# Presign in batches of 1000 — the server's per-call maximum.
for start in range(1, part_count + 1, 1000):
numbers = list(range(start, min(start + 1000, part_count + 1)))
parts = requests.post(
f"{BASE}/import/enex/uploads/{job}/parts",
headers=HEADERS,
json={"part_numbers": numbers},
).json()["data"]["parts"]
for part in parts:
# Read one slice at a time; never load the whole export.
fh.seek((part["part_number"] - 1) * part_size)
put = requests.put(part["url"], data=fh.read(part_size))
put.raise_for_status()
# The ETag is the only proof the part landed. Keep every one.
etags.append({
"part_number": part["part_number"],
"etag": put.headers["ETag"].strip('"'),
})
requests.post(
f"{BASE}/import/enex/uploads/{job}/complete", headers=HEADERS, json={"parts": etags}
).raise_for_status()
while True:
j = requests.get(f"{BASE}/import/enex/{job}", headers=HEADERS).json()["data"]
if j["status"] in ("completed", "partial", "failed", "aborted"):
print(j["status"], j.get("failure_reason", ""), j["imported_notes"])
break
time.sleep(5)
JavaScript
import { open, stat } from "node:fs/promises";
const BASE = "https://app.harbor.my/api/v1";
const headers = {
Authorization: `Bearer ${process.env.HARBOR_TOKEN}`,
"Content-Type": "application/json",
};
const PATH = "Contractors.enex";
const { size } = await stat(PATH);
const created = await (await fetch(`${BASE}/import/enex/uploads`, {
method: "POST",
headers,
body: JSON.stringify({ total_bytes: size, filename: PATH }),
})).json();
const { import_job_id: job, part_size: partSize, part_count: partCount } = created.data;
const fh = await open(PATH, "r");
const etags = [];
for (let start = 1; start <= partCount; start += 1000) {
const numbers = [];
for (let n = start; n < Math.min(start + 1000, partCount + 1); n++) numbers.push(n);
const presigned = await (await fetch(`${BASE}/import/enex/uploads/${job}/parts`, {
method: "POST",
headers,
body: JSON.stringify({ part_numbers: numbers }),
})).json();
for (const part of presigned.data.parts) {
// One slice at a time — the whole export never sits in memory.
const buf = Buffer.alloc(partSize);
const { bytesRead } = await fh.read(buf, 0, partSize, (part.part_number - 1) * partSize);
const put = await fetch(part.url, { method: "PUT", body: buf.subarray(0, bytesRead) });
if (!put.ok) throw new Error(`part ${part.part_number}: ${put.status}`);
// Keep the ETag from every PUT; complete is rejected without them.
etags.push({ part_number: part.part_number, etag: put.headers.get("etag").replaceAll('"', "") });
}
}
await fh.close();
await fetch(`${BASE}/import/enex/uploads/${job}/complete`, {
method: "POST",
headers,
body: JSON.stringify({ parts: etags }),
});
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
const base = "https://app.harbor.my/api/v1"
func main() {
token := os.Getenv("HARBOR_TOKEN")
path := "Contractors.enex"
fi, err := os.Stat(path)
if err != nil {
panic(err)
}
// 1. Create the upload and read back the chunking plan.
var created struct {
Data struct {
ImportJobID string `json:"import_job_id"`
PartSize int64 `json:"part_size"`
PartCount int `json:"part_count"`
} `json:"data"`
}
post(token, base+"/import/enex/uploads", map[string]any{
"total_bytes": fi.Size(),
"filename": path,
}, &created)
job, partSize, partCount := created.Data.ImportJobID, created.Data.PartSize, created.Data.PartCount
f, err := os.Open(path)
if err != nil {
panic(err)
}
defer f.Close()
type etag struct {
PartNumber int `json:"part_number"`
ETag string `json:"etag"`
}
var etags []etag
buf := make([]byte, partSize)
// 2 & 3. Presign in batches of 1000, then PUT each slice.
for start := 1; start <= partCount; start += 1000 {
var numbers []int
for n := start; n < min(start+1000, partCount+1); n++ {
numbers = append(numbers, n)
}
var presigned struct {
Data struct {
Parts []struct {
PartNumber int `json:"part_number"`
URL string `json:"url"`
} `json:"parts"`
} `json:"data"`
}
post(token, fmt.Sprintf("%s/import/enex/uploads/%s/parts", base, job),
map[string]any{"part_numbers": numbers}, &presigned)
for _, p := range presigned.Data.Parts {
// Read one slice; the export is never held in memory whole.
n, err := f.ReadAt(buf, int64(p.PartNumber-1)*partSize)
if err != nil && err != io.EOF {
panic(err)
}
req, _ := http.NewRequest(http.MethodPut, p.URL, bytes.NewReader(buf[:n]))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
res.Body.Close()
if res.StatusCode >= 300 {
panic(fmt.Sprintf("part %d: %s", p.PartNumber, res.Status))
}
// The ETag proves the part landed. Complete is rejected without it.
etags = append(etags, etag{p.PartNumber, strings.Trim(res.Header.Get("ETag"), `"`)})
}
}
// 4. Complete — the import is enqueued and you poll it from here.
post(token, fmt.Sprintf("%s/import/enex/uploads/%s/complete", base, job),
map[string]any{"parts": etags}, nil)
fmt.Println("import job:", job)
}
// post sends a JSON body to the Harbor API and decodes the reply into out,
// which may be nil when the caller does not need it.
func post(token, url string, body any, out any) {
b, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
if res.StatusCode >= 300 {
msg, _ := io.ReadAll(res.Body)
panic(fmt.Sprintf("%s: %s %s", url, res.Status, msg))
}
if out != nil {
json.NewDecoder(res.Body).Decode(out)
}
}
Poll an import job
GET /api/v1/import/enex/:job_id · scope: notes
Read a job’s live counters and per-note error list.
| Field | Type | Required | Description |
|---|---|---|---|
job_id | string (path) | yes | The import_job_id from the create call. |
curl https://app.harbor.my/api/v1/import/enex/0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response — 200 OK:
{
"data": {
"id": "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f",
"kind": "enex",
"status": "partial",
"filename": "Contractors.enex",
"total_bytes": 1073741824,
"total_notes": 12,
"imported_notes": 11,
"skipped_notes": 0,
"failed_notes": 1,
"failure_reason": "",
"errors": [
{ "note_index": 7, "title": "Broken note", "reason": "attachment_unreadable" }
],
"created_at": 1784131100000,
"updated_at": 1784131200000
}
}
- Counters climb while the job runs, persisted on a throttled cadence — by default at most once per second, or every 50 notes, whichever comes first — so polling every second or two is plenty.
- The worker pre-counts
<note>elements before importing, soimported_notes / total_notesis usually an accurate “X of Y” from the start. It isn’t guaranteed: the pre-count is a nicety the worker skips if it fails, and a cut-off file leavestotal_notesat0whileimported_notesclimbs. Guard the division. 404 not_found— unknown job id, or not the caller’s.
Python
import os
import time
import requests
BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
job_id = "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f"
# Poll until the job reaches a terminal status.
while True:
job = requests.get(f"{BASE}/import/enex/{job_id}", headers=HEADERS).json()["data"]
print(f"{job['status']}: {job['imported_notes']}/{job['total_notes']}")
if job["status"] not in ("queued", "running"):
break
time.sleep(2)
for e in job["errors"]:
print(f"note {e['note_index']} ({e['title']}): {e['reason']}")
JavaScript
const BASE = "https://app.harbor.my/api/v1";
const headers = { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` };
const jobId = "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f";
// Poll until the job reaches a terminal status.
let job;
do {
const res = await fetch(`${BASE}/import/enex/${jobId}`, { headers });
job = (await res.json()).data;
console.log(`${job.status}: ${job.imported_notes}/${job.total_notes}`);
if (job.status === "queued" || job.status === "running") {
await new Promise((r) => setTimeout(r, 2000));
}
} while (job.status === "queued" || job.status === "running");
for (const e of job.errors) {
console.log(`note ${e.note_index} (${e.title}): ${e.reason}`);
}
Go
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
const base = "https://app.harbor.my/api/v1"
func main() {
token := os.Getenv("HARBOR_TOKEN")
jobID := "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f"
// Poll until the job reaches a terminal status.
for {
req, _ := http.NewRequest("GET", base+"/import/enex/"+jobID, nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var out struct {
Data struct {
Status string `json:"status"`
TotalNotes int `json:"total_notes"`
ImportedNotes int `json:"imported_notes"`
FailedNotes int `json:"failed_notes"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
res.Body.Close()
j := out.Data
fmt.Printf("%s: %d/%d\n", j.Status, j.ImportedNotes, j.TotalNotes)
if j.Status != "queued" && j.Status != "running" {
break
}
time.Sleep(2 * time.Second)
}
}
List import jobs
GET /api/v1/import/enex · scope: notes
List the caller’s active and recently finished import jobs — so a client that
reloaded mid-import can rediscover an in-flight job without having held onto
its job_id. The array contains every in-flight job (queued/running),
newest first, plus — when nothing is in flight — the single most recent job
that reached a terminal status within the last 2 minutes. Elements have the
same shape as the single-job status response, and the list is scoped to the
caller.
curl https://app.harbor.my/api/v1/import/enex \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response — 200 OK (data-wrapped array, never null; empty means no active
or recently finished import):
{
"data": [
{
"id": "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f",
"kind": "enex",
"status": "running",
"filename": "Contractors.enex",
"total_bytes": 1073741824,
"total_notes": 120,
"imported_notes": 38,
"skipped_notes": 0,
"failed_notes": 0,
"failure_reason": "",
"errors": [],
"created_at": 1784131100000,
"updated_at": 1784131200000
}
]
}
Export to ENEX
POST /api/v1/export/enex · scope: notes
Export a notebook or an explicit note selection to a valid, round-trippable
<en-export> document. The response is the raw .enex file, not a JSON
envelope: Content-Type: application/enex+xml with a Content-Disposition
filename. Targeting is exclusive — provide exactly one of notebook_id or
note_ids.
| Field | Type | Required | Description |
|---|---|---|---|
notebook_id | string | one of | Export every live note in this notebook. |
note_ids | string[] | one of | Export exactly these notes. |
include_resources | bool | no | When true, each linked attachment’s bytes are inlined as base64 <resource> blocks. |
# Export a whole notebook, attachments included.
curl https://app.harbor.my/api/v1/export/enex \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"notebook_id": "5b1f2c9a-8d3e-4f6a-b1c2-7e9d0a4f5b6c", "include_resources": true}' \
-o notebook.enex
# Export a hand-picked selection of notes.
curl https://app.harbor.my/api/v1/export/enex \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"note_ids": ["a3d81c47-2e6f-4b90-8c15-d94e7f2a6b03"], "include_resources": true}' \
-o notes.enex
Response — 200 OK — the ENEX XML document; the body is the file bytes.
- Encrypted notes are skipped. An encrypted note holds only ciphertext,
which isn’t valid ENML, so it’s omitted; the skipped count is reported in
the
X-Skipped-Encryptedresponse header (the body is raw XML, so there’s no JSON field to carry it). 422 validation_failed— invalid JSON, or not exactly one ofnotebook_id/note_ids(both or neither provided).404 not_found— the notebook, or any listed note id, doesn’t exist or is trashed.
The Markdown round trip
The account export can produce
a Markdown archive, and it is deliberately the same dialect this surface
imports: unzip it, and it is an Obsidian vault. Feeding that ZIP back through
the import flow as kind obsidian_zip restores the notebooks, tags, created
and updated timestamps, attachments and inter-note wikilinks it carries.
One exception is worth knowing before you build on it. Harbor tasks are
written into the Markdown text using the Obsidian Tasks emoji convention
(📅 due, ⏰ reminder, 🔁 recurrence, ✅ completion, 🚩 flag), so no
field is silently dropped — but the Markdown importer has no task parsing, so
re-importing turns each one back into a plain editor checklist. ENEX is the
format with a lossless task round trip; it carries every task field as a real
field.
Related
- Account API — the async whole-account and per-notebook export in ENEX, HTML, and Markdown.
- Notes API — what imported notes become, including web clips.
- Files API — how attachments are stored, thumbnailed, and OCR’d.
- Switch from Evernote — the end-to-end migration guide this API powers.