Harbor
Developer docs menu

API reference

Import & Export API

Import an Evernote .enex file 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 source attribute like web.clip (or, for marker-less exports, a source-url plus deep <div> nesting) is flagged is_web_clip and 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 — inline, background, or direct-to-storage — is tracked as a job. The create call returns the id as import_job_id; status reads return it as id.

FieldTypeDescription
idstringJob id (import_job_id in create responses).
statusstringOne of awaiting_upload, queued, running, completed, partial, failed, aborted.
total_notesintNotes in the file. For direct-to-storage imports the worker pre-counts <note> elements, so this is accurate from the start of processing.
imported_notesintNotes imported so far — climbs live while running.
skipped_notesintNotes skipped.
failed_notesintNotes that failed to import (see errors).
errorsarrayUp 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_atintLast progress persist, UTC epoch milliseconds.
{
  "id": "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f",
  "status": "running",
  "total_notes": 120,
  "imported_notes": 38,
  "skipped_notes": 0,
  "failed_notes": 0,
  "errors": [],
  "updated_at": 1784131200000
}

Lifecycle. A multipart import starts at queued (or finishes inline — see below); a direct-to-storage import starts at awaiting_upload until you complete the upload. Jobs run through running to one terminal state:

  • completed — every note imported.
  • partial — the whole file was read but some notes failed; the rest imported fine. Check errors.
  • failed — a job-level failure, including an incomplete read: a truncated upload is always marked failed, never a misleading partial, and total_notes never pretends the whole file was read.
  • aborted — a direct upload was cancelled.

Start an ENEX import

POST /api/v1/import/enex · scope: notes

Upload a .enex file as multipart/form-data. Small imports (at most 25 notes and 5 MiB, by default) run inline and return 201 with a finished job; larger ones enqueue a background job and return 202 with a job id to poll. The hard cap is 1 GiB — for multi-GB files use the direct-to-storage flow instead.

FieldTypeRequiredDescription
filefileyesThe .enex bytes.
target_notebook_idstringnoPut every note in this notebook. Must exist and must not be encrypted. When omitted, notes go into a new notebook named after the uploaded file’s basename (Contractors.enexContractors), created once and reused across retries.
filenamestringnoOverrides the auto-created notebook’s name (fallback: Imported Notes).
notify_emailbooleannoEmail an import-complete message when an async import finishes. Defaults on; send false to opt out. Inline (201) imports never email.
curl https://app.harbor.my/api/v1/import/enex \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -F "file=@Contractors.enex" \
  -F "notify_email=false"

Response — 201 Created (ran inline) or 202 Accepted (enqueued):

{
  "data": {
    "import_job_id": "0f9c2b1e-7c4d-4b8a-9e2f-3a5d6c8b0e1f",
    "status": "completed",
    "total_notes": 12,
    "imported_notes": 11,
    "skipped_notes": 0,
    "failed_notes": 1
  }
}

Errors:

  • 422 validation_failed — no multipart file field.
  • 422 enex_too_large — over the 1 GiB hard cap.
  • 422 invalid_enex — not well-formed XML, or not an <en-export>.
  • 404 not_foundtarget_notebook_id doesn’t exist or is trashed.
  • 422 cannot_import_into_encryptedtarget_notebook_id is an encrypted notebook.

Python

import os
import requests

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

# Start the import: multipart upload of the .enex file.
with open("Contractors.enex", "rb") as f:
    r = requests.post(
        f"{BASE}/import/enex",
        headers=HEADERS,
        files={"file": ("Contractors.enex", f)},
        data={"notify_email": "false"},
    )
r.raise_for_status()

job = r.json()["data"]
print(job["import_job_id"], job["status"])  # 201 = done inline, 202 = poll it

JavaScript

import { openAsBlob } from "node:fs";

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

// Start the import: multipart upload of the .enex file.
const form = new FormData();
form.set("file", await openAsBlob("Contractors.enex"), "Contractors.enex");
form.set("notify_email", "false");

const res = await fetch(`${BASE}/import/enex`, {
  method: "POST",
  headers, // fetch sets the multipart boundary itself
  body: form,
});

const { data: job } = await res.json();
console.log(job.import_job_id, job.status); // 201 = done inline, 202 = poll it

Go

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
)

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

func main() {
	token := os.Getenv("HARBOR_TOKEN")

	// Build the multipart body with the .enex file.
	var buf bytes.Buffer
	w := multipart.NewWriter(&buf)
	part, _ := w.CreateFormFile("file", "Contractors.enex")
	f, err := os.Open("Contractors.enex")
	if err != nil {
		panic(err)
	}
	io.Copy(part, f)
	f.Close()
	w.WriteField("notify_email", "false")
	w.Close()

	// Start the import.
	req, _ := http.NewRequest("POST", base+"/import/enex", &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", w.FormDataContentType())

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var out struct {
		Data struct {
			ImportJobID string `json:"import_job_id"`
			Status      string `json:"status"`
		} `json:"data"`
	}
	json.NewDecoder(res.Body).Decode(&out)
	fmt.Println(out.Data.ImportJobID, out.Data.Status) // 201 = inline, 202 = poll
}

Poll an import job

GET /api/v1/import/enex/:job_id · scope: notes

Read a job’s live counters and per-note error list.

FieldTypeRequiredDescription
job_idstring (path)yesThe 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",
    "status": "partial",
    "total_notes": 12,
    "imported_notes": 11,
    "skipped_notes": 0,
    "failed_notes": 1,
    "errors": [
      { "note_index": 7, "title": "Broken note", "reason": "resource 0: invalid base64 data" }
    ],
    "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.
  • For a direct-to-storage import the worker pre-counts <note> elements before importing, so imported_notes / total_notes is an accurate “X of Y” from the start.
  • 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",
      "status": "running",
      "total_notes": 120,
      "imported_notes": 38,
      "skipped_notes": 0,
      "failed_notes": 0,
      "errors": [],
      "updated_at": 1784131200000
    }
  ]
}

Large imports: the direct-to-storage flow

POST /import/enex proxies the whole file through the API — fine for small and medium files, but not for a multi-GB Evernote export. For those (up to 100 GB+), the client uploads the .enex straight to object storage via presigned multipart URLs, and a background worker stream-parses one <note> at a time; the bytes never travel through the API. The flow is four small metadata calls:

  1. Create the upload — declare the file size, get a chunking plan.
  2. Presign parts — get PUT URLs for a batch of part numbers.
  3. PUT each part to its URL, keeping the ETag from each response.
  4. Complete — hand back the ETags; the import is enqueued and you poll it like any other job.

The staged file lives at a transient cache key and is deleted once the import reaches a terminal state. If you host Harbor yourself, the bucket must allow cross-origin PUT and expose the ETag response header (ExposeHeaders: ETag), or the client can’t complete the upload.

Create a direct upload

POST /api/v1/import/enex/uploads · scope: notes

Creates the job in status awaiting_upload and returns the chunking plan.

FieldTypeRequiredDescription
total_bytesintyesThe .enex size in bytes. Must be greater than 0 and within the 1 GiB-default cap.
filenamestringnoNames the auto-created notebook (same rules as the multipart path).
target_notebook_idstringnoMust exist and must not be encrypted.
notify_emailbooleannoDefaults on; 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.

FieldTypeRequiredDescription
part_numbersint[]yesPart 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, exactly like the multipart path.

FieldTypeRequiredDescription
partsarrayyesOne {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 — empty parts, or the job is no longer awaiting an upload.
  • 422 import_upload_incomplete — integrity check: the assembled object must be exactly the declared total_bytes. If it’s smaller, the short object is deleted, the job is marked failed, 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).

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.

FieldTypeRequiredDescription
notebook_idstringone ofExport every live note in this notebook.
note_idsstring[]one ofExport exactly these notes.
include_resourcesboolnoWhen 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-Encrypted response header (the body is raw XML, so there’s no JSON field to carry it).
  • 422 validation_failed — invalid JSON, or not exactly one of notebook_id / note_ids (both or neither provided).
  • 404 not_found — the notebook, or any listed note id, doesn’t exist or is trashed.
  • 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.