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
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 — 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.
| Field | Type | Description |
|---|---|---|
id | string | Job id (import_job_id in create responses). |
status | string | One of awaiting_upload, queued, running, completed, partial, failed, aborted. |
total_notes | int | Notes in the file. For direct-to-storage imports the worker pre-counts <note> elements, so this is accurate from the start of processing. |
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). |
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",
"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. Checkerrors.failed— a job-level failure, including an incomplete read: a truncated upload is always markedfailed, never a misleadingpartial, andtotal_notesnever 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.
| Field | Type | Required | Description |
|---|---|---|---|
file | file | yes | The .enex bytes. |
target_notebook_id | string | no | Put 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.enex → Contractors), created once and reused across retries. |
filename | string | no | Overrides the auto-created notebook’s name (fallback: Imported Notes). |
notify_email | boolean | no | Email 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 multipartfilefield.422 enex_too_large— over the 1 GiB hard cap.422 invalid_enex— not well-formed XML, or not an<en-export>.404 not_found—target_notebook_iddoesn’t exist or is trashed.422 cannot_import_into_encrypted—target_notebook_idis 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.
| 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",
"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, soimported_notes / total_notesis 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:
- Create the upload — declare the file size, get a chunking plan.
- Presign parts — get PUT URLs for a batch of part numbers.
- PUT each part to its URL, keeping the
ETagfrom each response. - 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.
| Field | Type | Required | Description |
|---|---|---|---|
total_bytes | int | yes | The .enex size in bytes. Must be greater than 0 and within the 1 GiB-default cap. |
filename | string | no | Names the auto-created notebook (same rules as the multipart path). |
target_notebook_id | string | no | Must exist and must not be encrypted. |
notify_email | boolean | no | Defaults 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.
| 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, exactly like the multipart path.
| 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).
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.
Related
- 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.