Files
Trilium/docs/Developer Guide/API Documentation/ETAPI Complete Guide.md
2025-08-21 15:55:44 +00:00

48 KiB
Vendored

ETAPI Complete Guide

Table of Contents

  1. Introduction
  2. Authentication Setup
  3. API Endpoints
  4. Common Use Cases
  5. Client Library Examples
  6. Rate Limiting and Best Practices
  7. Migration from Internal API
  8. Error Handling
  9. Performance Considerations

Introduction

ETAPI (External Trilium API) is the recommended REST API for external integrations with Trilium Notes. It provides a secure, stable interface for programmatic access to notes, attributes, branches, and attachments.

Key Features

  • RESTful design with predictable endpoints
  • Token-based authentication
  • Comprehensive CRUD operations
  • Search functionality
  • Import/export capabilities
  • Calendar and special note access

Base URL

http://localhost:8080/etapi

Authentication Setup

Method 1: Token Authentication

Step 1: Generate ETAPI Token

  1. Open Trilium Notes
  2. Navigate to OptionsETAPI
  3. Click "Create new ETAPI token"
  4. Copy the generated token

Step 2: Use Token in Requests

HTTP Header:

Authorization: <your-token>

cURL Example:

curl -X GET http://localhost:8080/etapi/notes/root \
  -H "Authorization: myEtapiToken123"

Python Example:

import requests

headers = {
    'Authorization': 'myEtapiToken123'
}

response = requests.get('http://localhost:8080/etapi/notes/root', headers=headers)
print(response.json())

Method 2: Basic Authentication

Use the ETAPI token as the password with any username:

curl -X GET http://localhost:8080/etapi/notes/root \
  -u "trilium:myEtapiToken123"

Method 3: Programmatic Login

import requests

# Login to get token
login_data = {'password': 'your-trilium-password'}
response = requests.post('http://localhost:8080/etapi/auth/login', json=login_data)
token = response.json()['authToken']

# Use token for subsequent requests
headers = {'Authorization': token}
notes = requests.get('http://localhost:8080/etapi/notes/root', headers=headers)

API Endpoints

Notes

Create Note

POST /etapi/create-note

Creates a new note and places it in the tree.

Request Body:

{
  "parentNoteId": "root",
  "title": "My New Note",
  "type": "text",
  "content": "<p>This is the note content</p>",
  "notePosition": 10,
  "prefix": "📝",
  "isExpanded": true
}

Response (201 Created):

{
  "note": {
    "noteId": "evnnmvHTCgIn",
    "title": "My New Note",
    "type": "text",
    "mime": "text/html",
    "isProtected": false,
    "dateCreated": "2024-01-15 10:30:00.000+0100",
    "dateModified": "2024-01-15 10:30:00.000+0100",
    "utcDateCreated": "2024-01-15 09:30:00.000Z",
    "utcDateModified": "2024-01-15 09:30:00.000Z"
  },
  "branch": {
    "branchId": "ibhg4WxTdULk",
    "noteId": "evnnmvHTCgIn",
    "parentNoteId": "root",
    "prefix": "📝",
    "notePosition": 10,
    "isExpanded": true,
    "utcDateModified": "2024-01-15 09:30:00.000Z"
  }
}

Python Example:

import requests
import json

def create_note(parent_id, title, content, note_type="text"):
    url = "http://localhost:8080/etapi/create-note"
    headers = {
        'Authorization': 'your-token',
        'Content-Type': 'application/json'
    }
    
    data = {
        "parentNoteId": parent_id,
        "title": title,
        "type": note_type,
        "content": content
    }
    
    response = requests.post(url, headers=headers, json=data)
    
    if response.status_code == 201:
        return response.json()
    else:
        raise Exception(f"Failed to create note: {response.text}")

# Usage
new_note = create_note("root", "Meeting Notes", "<p>Discussion points:</p><ul><li>Item 1</li></ul>")
print(f"Created note with ID: {new_note['note']['noteId']}")

Get Note by ID

GET /etapi/notes/{noteId}

cURL Example:

curl -X GET http://localhost:8080/etapi/notes/evnnmvHTCgIn \
  -H "Authorization: your-token"

Response:

{
  "noteId": "evnnmvHTCgIn",
  "title": "My Note",
  "type": "text",
  "mime": "text/html",
  "isProtected": false,
  "attributes": [
    {
      "attributeId": "abc123",
      "noteId": "evnnmvHTCgIn",
      "type": "label",
      "name": "todo",
      "value": "",
      "position": 10,
      "isInheritable": false
    }
  ],
  "parentNoteIds": ["root"],
  "childNoteIds": ["child1", "child2"],
  "dateCreated": "2024-01-15 10:30:00.000+0100",
  "dateModified": "2024-01-15 14:20:00.000+0100",
  "utcDateCreated": "2024-01-15 09:30:00.000Z",
  "utcDateModified": "2024-01-15 13:20:00.000Z"
}

Update Note

PATCH /etapi/notes/{noteId}

Request Body:

{
  "title": "Updated Title",
  "type": "text",
  "mime": "text/html"
}

JavaScript Example:

async function updateNote(noteId, updates) {
    const response = await fetch(`http://localhost:8080/etapi/notes/${noteId}`, {
        method: 'PATCH',
        headers: {
            'Authorization': 'your-token',
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(updates)
    });
    
    if (!response.ok) {
        throw new Error(`Failed to update note: ${response.statusText}`);
    }
    
    return response.json();
}

// Usage
updateNote('evnnmvHTCgIn', { title: 'New Title' })
    .then(note => console.log('Updated note:', note))
    .catch(err => console.error('Error:', err));

Delete Note

DELETE /etapi/notes/{noteId}

curl -X DELETE http://localhost:8080/etapi/notes/evnnmvHTCgIn \
  -H "Authorization: your-token"

Get Note Content

GET /etapi/notes/{noteId}/content

Returns the content of a note.

import requests

def get_note_content(note_id, token):
    url = f"http://localhost:8080/etapi/notes/{note_id}/content"
    headers = {'Authorization': token}
    
    response = requests.get(url, headers=headers)
    return response.text  # Returns HTML or text content

content = get_note_content('evnnmvHTCgIn', 'your-token')
print(content)

Update Note Content

PUT /etapi/notes/{noteId}/content

def update_note_content(note_id, content, token):
    url = f"http://localhost:8080/etapi/notes/{note_id}/content"
    headers = {
        'Authorization': token,
        'Content-Type': 'text/plain'
    }
    
    response = requests.put(url, headers=headers, data=content)
    return response.status_code == 204

# Update with HTML content
html_content = "<h1>Updated Content</h1><p>New paragraph</p>"
success = update_note_content('evnnmvHTCgIn', html_content, 'your-token')

Search Notes

GET /etapi/notes

Search for notes using Trilium's search syntax.

Query Parameters:

  • search (required): Search query string
  • fastSearch: Enable fast search (default: false)
  • includeArchivedNotes: Include archived notes (default: false)
  • ancestorNoteId: Search within subtree
  • ancestorDepth: Depth constraint (e.g., "eq1", "lt4", "gt2")
  • orderBy: Property to order by
  • orderDirection: "asc" or "desc"
  • limit: Maximum number of results
  • debug: Include debug information

Examples:

# Full-text search
def search_notes(query, token, **kwargs):
    url = "http://localhost:8080/etapi/notes"
    headers = {'Authorization': token}
    params = {'search': query, **kwargs}
    
    response = requests.get(url, headers=headers, params=params)
    return response.json()

# Search for keyword
results = search_notes("project management", token)

# Search with label
results = search_notes("#todo", token)

# Search for exact phrase
results = search_notes('"exact phrase"', token)

# Complex search with ordering and limit
results = search_notes(
    "type:text #important", 
    token,
    orderBy="dateModified",
    orderDirection="desc",
    limit=10
)

# Search in subtree
results = search_notes(
    "API", 
    token,
    ancestorNoteId="documentation_root",
    ancestorDepth="lt5"
)

cURL Examples:

# Simple keyword search
curl -G "http://localhost:8080/etapi/notes" \
  -H "Authorization: your-token" \
  --data-urlencode "search=javascript"

# Search with multiple criteria
curl -G "http://localhost:8080/etapi/notes" \
  -H "Authorization: your-token" \
  --data-urlencode "search=#todo #priority" \
  --data-urlencode "orderBy=dateCreated" \
  --data-urlencode "orderDirection=desc" \
  --data-urlencode "limit=20"

Attributes

Create Attribute

POST /etapi/attributes

{
  "noteId": "evnnmvHTCgIn",
  "type": "label",
  "name": "priority",
  "value": "high",
  "position": 10,
  "isInheritable": false
}

Python Example:

def add_label(note_id, name, value="", inheritable=False):
    url = "http://localhost:8080/etapi/attributes"
    headers = {
        'Authorization': 'your-token',
        'Content-Type': 'application/json'
    }
    
    data = {
        "noteId": note_id,
        "type": "label",
        "name": name,
        "value": value,
        "isInheritable": inheritable
    }
    
    response = requests.post(url, headers=headers, json=data)
    return response.json()

# Add a todo label
add_label("evnnmvHTCgIn", "todo")

# Add a priority label with value
add_label("evnnmvHTCgIn", "priority", "high")

Get Attribute

GET /etapi/attributes/{attributeId}

Update Attribute

PATCH /etapi/attributes/{attributeId}

Only value and position can be updated for labels, only position for relations.

async function updateAttributeValue(attributeId, newValue) {
    const response = await fetch(`http://localhost:8080/etapi/attributes/${attributeId}`, {
        method: 'PATCH',
        headers: {
            'Authorization': 'your-token',
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ value: newValue })
    });
    
    return response.json();
}

Delete Attribute

DELETE /etapi/attributes/{attributeId}

Branches

Create Branch (Clone Note)

POST /etapi/branches

Creates a branch (clone) of a note to another location.

{
  "noteId": "evnnmvHTCgIn",
  "parentNoteId": "targetParent",
  "prefix": "Clone: ",
  "notePosition": 10
}

Python Example:

def clone_note(note_id, new_parent_id, prefix=""):
    url = "http://localhost:8080/etapi/branches"
    headers = {
        'Authorization': 'your-token',
        'Content-Type': 'application/json'
    }
    
    data = {
        "noteId": note_id,
        "parentNoteId": new_parent_id,
        "prefix": prefix
    }
    
    response = requests.post(url, headers=headers, json=data)
    return response.json()

# Clone a note to multiple locations
clone_note("templateNote", "project1", "Task: ")
clone_note("templateNote", "project2", "Task: ")

Update Branch

PATCH /etapi/branches/{branchId}

Only prefix and notePosition can be updated.

curl -X PATCH http://localhost:8080/etapi/branches/branchId123 \
  -H "Authorization: your-token" \
  -H "Content-Type: application/json" \
  -d '{"notePosition": 5, "prefix": "📌 "}'

Delete Branch

DELETE /etapi/branches/{branchId}

Deletes a branch. If this is the last branch of a note, the note is deleted too.

Attachments

Create Attachment

POST /etapi/attachments

{
  "ownerId": "evnnmvHTCgIn",
  "role": "file",
  "mime": "application/pdf",
  "title": "document.pdf",
  "content": "base64-encoded-content",
  "position": 10
}

Python Example with File Upload:

import base64

def upload_attachment(note_id, file_path, title=None):
    with open(file_path, 'rb') as f:
        content = base64.b64encode(f.read()).decode('utf-8')
    
    import mimetypes
    mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
    
    if title is None:
        import os
        title = os.path.basename(file_path)
    
    url = "http://localhost:8080/etapi/attachments"
    headers = {
        'Authorization': 'your-token',
        'Content-Type': 'application/json'
    }
    
    data = {
        "ownerId": note_id,
        "role": "file",
        "mime": mime_type,
        "title": title,
        "content": content
    }
    
    response = requests.post(url, headers=headers, json=data)
    return response.json()

# Upload a PDF
attachment = upload_attachment("evnnmvHTCgIn", "/path/to/document.pdf")
print(f"Attachment ID: {attachment['attachmentId']}")

Get Attachment Content

GET /etapi/attachments/{attachmentId}/content

def download_attachment(attachment_id, output_path):
    url = f"http://localhost:8080/etapi/attachments/{attachment_id}/content"
    headers = {'Authorization': 'your-token'}
    
    response = requests.get(url, headers=headers)
    
    with open(output_path, 'wb') as f:
        f.write(response.content)
    
    return output_path

# Download attachment
download_attachment("attachId123", "/tmp/downloaded.pdf")

Special Notes

Get Inbox Note

GET /etapi/inbox/{date}

Gets or creates an inbox note for the specified date.

from datetime import date

def get_inbox_note(target_date=None):
    if target_date is None:
        target_date = date.today()
    
    date_str = target_date.strftime('%Y-%m-%d')
    url = f"http://localhost:8080/etapi/inbox/{date_str}"
    headers = {'Authorization': 'your-token'}
    
    response = requests.get(url, headers=headers)
    return response.json()

# Get today's inbox
inbox = get_inbox_note()
print(f"Inbox note ID: {inbox['noteId']}")

Calendar Notes

Day Note:

def get_day_note(date_str):
    url = f"http://localhost:8080/etapi/calendar/days/{date_str}"
    headers = {'Authorization': 'your-token'}
    response = requests.get(url, headers=headers)
    return response.json()

day_note = get_day_note("2024-01-15")

Week Note:

def get_week_note(date_str):
    url = f"http://localhost:8080/etapi/calendar/weeks/{date_str}"
    headers = {'Authorization': 'your-token'}
    response = requests.get(url, headers=headers)
    return response.json()

week_note = get_week_note("2024-01-15")

Month Note:

def get_month_note(month_str):
    url = f"http://localhost:8080/etapi/calendar/months/{month_str}"
    headers = {'Authorization': 'your-token'}
    response = requests.get(url, headers=headers)
    return response.json()

month_note = get_month_note("2024-01")

Year Note:

def get_year_note(year):
    url = f"http://localhost:8080/etapi/calendar/years/{year}"
    headers = {'Authorization': 'your-token'}
    response = requests.get(url, headers=headers)
    return response.json()

year_note = get_year_note("2024")

Import/Export

Export Note Subtree

GET /etapi/notes/{noteId}/export

Exports a note subtree as a ZIP file.

Query Parameters:

  • format: "html" (default) or "markdown"
def export_subtree(note_id, output_file, format="html"):
    url = f"http://localhost:8080/etapi/notes/{note_id}/export"
    headers = {'Authorization': 'your-token'}
    params = {'format': format}
    
    response = requests.get(url, headers=headers, params=params)
    
    with open(output_file, 'wb') as f:
        f.write(response.content)
    
    return output_file

# Export entire database
export_subtree("root", "backup.zip")

# Export specific subtree as markdown
export_subtree("projectNoteId", "project.zip", format="markdown")

Import ZIP

POST /etapi/notes/{noteId}/import

Imports a ZIP file into a note.

def import_zip(parent_note_id, zip_file_path):
    url = f"http://localhost:8080/etapi/notes/{parent_note_id}/import"
    headers = {'Authorization': 'your-token'}
    
    with open(zip_file_path, 'rb') as f:
        files = {'file': f}
        response = requests.post(url, headers=headers, files=files)
    
    return response.json()

# Import backup
imported = import_zip("root", "backup.zip")
print(f"Imported note ID: {imported['note']['noteId']}")

Utility Endpoints

Create Note Revision

POST /etapi/notes/{noteId}/revision

Forces creation of a revision for the specified note.

curl -X POST http://localhost:8080/etapi/notes/evnnmvHTCgIn/revision \
  -H "Authorization: your-token"

Refresh Note Ordering

POST /etapi/refresh-note-ordering/{parentNoteId}

Updates note ordering in connected clients after changing positions.

def reorder_children(parent_id, note_positions):
    """
    note_positions: dict of {noteId: position}
    """
    headers = {
        'Authorization': 'your-token',
        'Content-Type': 'application/json'
    }
    
    # Update each branch position
    for note_id, position in note_positions.items():
        # Get the branch ID first
        note = requests.get(
            f"http://localhost:8080/etapi/notes/{note_id}",
            headers=headers
        ).json()
        
        for branch_id in note['parentBranchIds']:
            branch = requests.get(
                f"http://localhost:8080/etapi/branches/{branch_id}",
                headers=headers
            ).json()
            
            if branch['parentNoteId'] == parent_id:
                # Update position
                requests.patch(
                    f"http://localhost:8080/etapi/branches/{branch_id}",
                    headers=headers,
                    json={'notePosition': position}
                )
    
    # Refresh ordering
    requests.post(
        f"http://localhost:8080/etapi/refresh-note-ordering/{parent_id}",
        headers=headers
    )

# Reorder notes
reorder_children("parentId", {
    "note1": 10,
    "note2": 20,
    "note3": 30
})

Get App Info

GET /etapi/app-info

Returns information about the Trilium instance.

def get_app_info():
    url = "http://localhost:8080/etapi/app-info"
    headers = {'Authorization': 'your-token'}
    response = requests.get(url, headers=headers)
    return response.json()

info = get_app_info()
print(f"Trilium version: {info['appVersion']}")
print(f"Database version: {info['dbVersion']}")
print(f"Data directory: {info['dataDirectory']}")

Create Backup

PUT /etapi/backup/{backupName}

Creates a database backup.

curl -X PUT http://localhost:8080/etapi/backup/daily \
  -H "Authorization: your-token"

This creates a backup file named backup-daily.db in the data directory.

Common Use Cases

1. Daily Journal Entry

from datetime import date
import requests

class TriliumJournal:
    def __init__(self, base_url, token):
        self.base_url = base_url
        self.headers = {'Authorization': token}
    
    def create_journal_entry(self, content, tags=[]):
        # Get today's day note
        today = date.today().strftime('%Y-%m-%d')
        day_note_url = f"{self.base_url}/calendar/days/{today}"
        day_note = requests.get(day_note_url, headers=self.headers).json()
        
        # Create entry
        entry_data = {
            "parentNoteId": day_note['noteId'],
            "title": f"Entry - {date.today().strftime('%H:%M')}",
            "type": "text",
            "content": content
        }
        
        response = requests.post(
            f"{self.base_url}/create-note",
            headers={**self.headers, 'Content-Type': 'application/json'},
            json=entry_data
        )
        
        entry = response.json()
        
        # Add tags
        for tag in tags:
            self.add_tag(entry['note']['noteId'], tag)
        
        return entry
    
    def add_tag(self, note_id, tag_name):
        attr_data = {
            "noteId": note_id,
            "type": "label",
            "name": tag_name,
            "value": ""
        }
        
        requests.post(
            f"{self.base_url}/attributes",
            headers={**self.headers, 'Content-Type': 'application/json'},
            json=attr_data
        )

# Usage
journal = TriliumJournal("http://localhost:8080/etapi", "your-token")
entry = journal.create_journal_entry(
    "<p>Today's meeting went well. Key decisions:</p><ul><li>Item 1</li></ul>",
    tags=["meeting", "important"]
)

2. Task Management System

class TriliumTaskManager:
    def __init__(self, base_url, token):
        self.base_url = base_url
        self.headers = {'Authorization': token}
        self.task_parent_id = self.get_or_create_task_root()
    
    def get_or_create_task_root(self):
        # Search for existing task root
        search_url = f"{self.base_url}/notes"
        params = {'search': '#taskRoot'}
        response = requests.get(search_url, headers=self.headers, params=params)
        results = response.json()['results']
        
        if results:
            return results[0]['noteId']
        
        # Create task root
        data = {
            "parentNoteId": "root",
            "title": "Tasks",
            "type": "text",
            "content": "<p>Task Management System</p>"
        }
        
        response = requests.post(
            f"{self.base_url}/create-note",
            headers={**self.headers, 'Content-Type': 'application/json'},
            json=data
        )
        
        note_id = response.json()['note']['noteId']
        
        # Add taskRoot label
        self.add_label(note_id, "taskRoot")
        return note_id
    
    def create_task(self, title, description, priority="medium", due_date=None):
        data = {
            "parentNoteId": self.task_parent_id,
            "title": title,
            "type": "text",
            "content": f"<p>{description}</p>"
        }
        
        response = requests.post(
            f"{self.base_url}/create-note",
            headers={**self.headers, 'Content-Type': 'application/json'},
            json=data
        )
        
        task = response.json()
        task_id = task['note']['noteId']
        
        # Add task attributes
        self.add_label(task_id, "task")
        self.add_label(task_id, "todoStatus", "todo")
        self.add_label(task_id, "priority", priority)
        
        if due_date:
            self.add_label(task_id, "dueDate", due_date)
        
        return task
    
    def get_tasks(self, status=None):
        if status:
            search = f"#task #todoStatus={status}"
        else:
            search = "#task"
        
        params = {
            'search': search,
            'ancestorNoteId': self.task_parent_id
        }
        
        response = requests.get(
            f"{self.base_url}/notes",
            headers=self.headers,
            params=params
        )
        
        return response.json()['results']
    
    def complete_task(self, task_id):
        # Find the todoStatus attribute
        note = requests.get(
            f"{self.base_url}/notes/{task_id}",
            headers=self.headers
        ).json()
        
        for attr in note['attributes']:
            if attr['name'] == 'todoStatus':
                # Update status
                requests.patch(
                    f"{self.base_url}/attributes/{attr['attributeId']}",
                    headers={**self.headers, 'Content-Type': 'application/json'},
                    json={'value': 'done'}
                )
                break
    
    def add_label(self, note_id, name, value=""):
        data = {
            "noteId": note_id,
            "type": "label",
            "name": name,
            "value": value
        }
        
        requests.post(
            f"{self.base_url}/attributes",
            headers={**self.headers, 'Content-Type': 'application/json'},
            json=data
        )

# Usage
tasks = TriliumTaskManager("http://localhost:8080/etapi", "your-token")

# Create tasks
task1 = tasks.create_task(
    "Review API documentation",
    "Check for completeness and accuracy",
    priority="high",
    due_date="2024-01-20"
)

task2 = tasks.create_task(
    "Update client library",
    "Add new ETAPI endpoints",
    priority="medium"
)

# List pending tasks
pending = tasks.get_tasks(status="todo")
for task in pending:
    print(f"- {task['title']}")

# Complete a task
tasks.complete_task(task1['note']['noteId'])

3. Knowledge Base Builder

class KnowledgeBase:
    def __init__(self, base_url, token):
        self.base_url = base_url
        self.headers = {'Authorization': token}
    
    def create_article(self, category, title, content, tags=[]):
        # Find or create category
        category_id = self.get_or_create_category(category)
        
        # Create article
        data = {
            "parentNoteId": category_id,
            "title": title,
            "type": "text",
            "content": content
        }
        
        response = requests.post(
            f"{self.base_url}/create-note",
            headers={**self.headers, 'Content-Type': 'application/json'},
            json=data
        )
        
        article = response.json()
        article_id = article['note']['noteId']
        
        # Add tags
        for tag in tags:
            self.add_label(article_id, tag)
        
        # Add article label
        self.add_label(article_id, "article")
        
        return article
    
    def get_or_create_category(self, name):
        # Search for existing category
        params = {'search': f'#category #categoryName={name}'}
        response = requests.get(
            f"{self.base_url}/notes",
            headers=self.headers,
            params=params
        )
        
        results = response.json()['results']
        if results:
            return results[0]['noteId']
        
        # Create new category
        data = {
            "parentNoteId": "root",
            "title": name,
            "type": "text",
            "content": f"<h1>{name}</h1>"
        }
        
        response = requests.post(
            f"{self.base_url}/create-note",
            headers={**self.headers, 'Content-Type': 'application/json'},
            json=data
        )
        
        category_id = response.json()['note']['noteId']
        
        self.add_label(category_id, "category")
        self.add_label(category_id, "categoryName", name)
        
        return category_id
    
    def search_articles(self, query):
        params = {
            'search': f'#article {query}',
            'orderBy': 'relevancy'
        }
        
        response = requests.get(
            f"{self.base_url}/notes",
            headers=self.headers,
            params=params
        )
        
        return response.json()['results']
    
    def add_label(self, note_id, name, value=""):
        data = {
            "noteId": note_id,
            "type": "label",
            "name": name,
            "value": value
        }
        
        requests.post(
            f"{self.base_url}/attributes",
            headers={**self.headers, 'Content-Type': 'application/json'},
            json=data
        )

# Usage
kb = KnowledgeBase("http://localhost:8080/etapi", "your-token")

# Add articles
article = kb.create_article(
    category="Python",
    title="Working with REST APIs",
    content="""
    <h2>Introduction</h2>
    <p>REST APIs are fundamental to modern web development...</p>
    <h2>Best Practices</h2>
    <ul>
        <li>Use proper HTTP methods</li>
        <li>Handle errors gracefully</li>
        <li>Implement retry logic</li>
    </ul>
    """,
    tags=["api", "rest", "tutorial"]
)

# Search articles
results = kb.search_articles("REST API")
for article in results:
    print(f"Found: {article['title']}")

Client Library Examples

JavaScript/TypeScript Client

class TriliumClient {
    private baseUrl: string;
    private token: string;
    
    constructor(baseUrl: string, token: string) {
        this.baseUrl = baseUrl;
        this.token = token;
    }
    
    private async request(
        endpoint: string, 
        options: RequestInit = {}
    ): Promise<any> {
        const url = `${this.baseUrl}${endpoint}`;
        const headers = {
            'Authorization': this.token,
            'Content-Type': 'application/json',
            ...options.headers
        };
        
        const response = await fetch(url, {
            ...options,
            headers
        });
        
        if (!response.ok) {
            const error = await response.json();
            throw new Error(`API Error: ${error.message}`);
        }
        
        if (response.status === 204) {
            return null;
        }
        
        return response.json();
    }
    
    async getNote(noteId: string) {
        return this.request(`/notes/${noteId}`);
    }
    
    async createNote(data: any) {
        return this.request('/create-note', {
            method: 'POST',
            body: JSON.stringify(data)
        });
    }
    
    async updateNote(noteId: string, updates: any) {
        return this.request(`/notes/${noteId}`, {
            method: 'PATCH',
            body: JSON.stringify(updates)
        });
    }
    
    async deleteNote(noteId: string) {
        return this.request(`/notes/${noteId}`, {
            method: 'DELETE'
        });
    }
    
    async searchNotes(query: string, options: any = {}) {
        const params = new URLSearchParams({
            search: query,
            ...options
        });
        
        return this.request(`/notes?${params}`);
    }
    
    async addAttribute(noteId: string, type: string, name: string, value = '') {
        return this.request('/attributes', {
            method: 'POST',
            body: JSON.stringify({
                noteId,
                type,
                name,
                value
            })
        });
    }
}

// Usage
const client = new TriliumClient('http://localhost:8080/etapi', 'your-token');

// Create a note
const note = await client.createNote({
    parentNoteId: 'root',
    title: 'New Note',
    type: 'text',
    content: '<p>Content</p>'
});

// Search notes
const results = await client.searchNotes('#todo', {
    orderBy: 'dateModified',
    orderDirection: 'desc',
    limit: 10
});

// Add a label
await client.addAttribute(note.note.noteId, 'label', 'important');

Python Client Class

import requests
from typing import Optional, Dict, List, Any
from datetime import datetime
import json

class TriliumETAPI:
    """Python client for Trilium ETAPI"""
    
    def __init__(self, base_url: str, token: str):
        self.base_url = base_url.rstrip('/')
        self.session = requests.Session()
        self.session.headers.update({
            'Authorization': token,
            'Content-Type': 'application/json'
        })
    
    def _request(self, method: str, endpoint: str, **kwargs) -> Any:
        """Make API request with error handling"""
        url = f"{self.base_url}{endpoint}"
        
        try:
            response = self.session.request(method, url, **kwargs)
            response.raise_for_status()
            
            if response.status_code == 204:
                return None
            
            return response.json() if response.content else None
            
        except requests.exceptions.HTTPError as e:
            if response.text:
                try:
                    error = response.json()
                    raise Exception(f"API Error {error.get('code')}: {error.get('message')}")
                except json.JSONDecodeError:
                    raise Exception(f"HTTP {response.status_code}: {response.text}")
            raise e
    
    # Note operations
    def create_note(
        self,
        parent_note_id: str,
        title: str,
        content: str,
        note_type: str = "text",
        **kwargs
    ) -> Dict:
        """Create a new note"""
        data = {
            "parentNoteId": parent_note_id,
            "title": title,
            "type": note_type,
            "content": content,
            **kwargs
        }
        return self._request('POST', '/create-note', json=data)
    
    def get_note(self, note_id: str) -> Dict:
        """Get note by ID"""
        return self._request('GET', f'/notes/{note_id}')
    
    def update_note(self, note_id: str, updates: Dict) -> Dict:
        """Update note properties"""
        return self._request('PATCH', f'/notes/{note_id}', json=updates)
    
    def delete_note(self, note_id: str) -> None:
        """Delete a note"""
        self._request('DELETE', f'/notes/{note_id}')
    
    def get_note_content(self, note_id: str) -> str:
        """Get note content"""
        response = self.session.get(f"{self.base_url}/notes/{note_id}/content")
        response.raise_for_status()
        return response.text
    
    def update_note_content(self, note_id: str, content: str) -> None:
        """Update note content"""
        headers = {'Content-Type': 'text/plain'}
        self.session.put(
            f"{self.base_url}/notes/{note_id}/content",
            data=content,
            headers=headers
        ).raise_for_status()
    
    # Search
    def search_notes(
        self,
        query: str,
        fast_search: bool = False,
        include_archived: bool = False,
        ancestor_note_id: Optional[str] = None,
        order_by: Optional[str] = None,
        order_direction: str = "asc",
        limit: Optional[int] = None
    ) -> List[Dict]:
        """Search for notes"""
        params = {
            'search': query,
            'fastSearch': fast_search,
            'includeArchivedNotes': include_archived
        }
        
        if ancestor_note_id:
            params['ancestorNoteId'] = ancestor_note_id
        if order_by:
            params['orderBy'] = order_by
            params['orderDirection'] = order_direction
        if limit:
            params['limit'] = limit
        
        result = self._request('GET', '/notes', params=params)
        return result.get('results', [])
    
    # Attributes
    def add_label(
        self,
        note_id: str,
        name: str,
        value: str = "",
        inheritable: bool = False
    ) -> Dict:
        """Add a label to a note"""
        data = {
            "noteId": note_id,
            "type": "label",
            "name": name,
            "value": value,
            "isInheritable": inheritable
        }
        return self._request('POST', '/attributes', json=data)
    
    def add_relation(
        self,
        note_id: str,
        name: str,
        target_note_id: str,
        inheritable: bool = False
    ) -> Dict:
        """Add a relation to a note"""
        data = {
            "noteId": note_id,
            "type": "relation",
            "name": name,
            "value": target_note_id,
            "isInheritable": inheritable
        }
        return self._request('POST', '/attributes', json=data)
    
    def update_attribute(self, attribute_id: str, updates: Dict) -> Dict:
        """Update an attribute"""
        return self._request('PATCH', f'/attributes/{attribute_id}', json=updates)
    
    def delete_attribute(self, attribute_id: str) -> None:
        """Delete an attribute"""
        self._request('DELETE', f'/attributes/{attribute_id}')
    
    # Branches
    def clone_note(
        self,
        note_id: str,
        parent_note_id: str,
        prefix: str = ""
    ) -> Dict:
        """Clone a note to another location"""
        data = {
            "noteId": note_id,
            "parentNoteId": parent_note_id,
            "prefix": prefix
        }
        return self._request('POST', '/branches', json=data)
    
    def move_note(
        self,
        note_id: str,
        new_parent_id: str
    ) -> None:
        """Move a note to a new parent"""
        # Get current branches
        note = self.get_note(note_id)
        
        # Delete old branches
        for branch_id in note['parentBranchIds']:
            self._request('DELETE', f'/branches/{branch_id}')
        
        # Create new branch
        self.clone_note(note_id, new_parent_id)
    
    # Special notes
    def get_inbox(self, date: Optional[datetime] = None) -> Dict:
        """Get inbox note for a date"""
        if date is None:
            date = datetime.now()
        date_str = date.strftime('%Y-%m-%d')
        return self._request('GET', f'/inbox/{date_str}')
    
    def get_day_note(self, date: Optional[datetime] = None) -> Dict:
        """Get day note for a date"""
        if date is None:
            date = datetime.now()
        date_str = date.strftime('%Y-%m-%d')
        return self._request('GET', f'/calendar/days/{date_str}')
    
    # Utility
    def get_app_info(self) -> Dict:
        """Get application information"""
        return self._request('GET', '/app-info')
    
    def create_backup(self, name: str) -> None:
        """Create a backup"""
        self._request('PUT', f'/backup/{name}')
    
    def export_subtree(
        self,
        note_id: str,
        format: str = "html"
    ) -> bytes:
        """Export note subtree as ZIP"""
        params = {'format': format}
        response = self.session.get(
            f"{self.base_url}/notes/{note_id}/export",
            params=params
        )
        response.raise_for_status()
        return response.content

# Example usage
if __name__ == "__main__":
    # Initialize client
    api = TriliumETAPI("http://localhost:8080/etapi", "your-token")
    
    # Create a note
    note = api.create_note(
        parent_note_id="root",
        title="API Test Note",
        content="<p>Created via Python client</p>"
    )
    print(f"Created note: {note['note']['noteId']}")
    
    # Add labels
    api.add_label(note['note']['noteId'], "test")
    api.add_label(note['note']['noteId'], "priority", "high")
    
    # Search
    results = api.search_notes("#test", limit=10)
    for result in results:
        print(f"Found: {result['title']}")
    
    # Export backup
    backup_data = api.export_subtree("root")
    with open("backup.zip", "wb") as f:
        f.write(backup_data)

Rate Limiting and Best Practices

Rate Limiting

ETAPI implements rate limiting for authentication endpoints:

  • Login endpoint: Maximum 10 requests per IP per hour
  • Other endpoints: No specific rate limits, but excessive requests may be throttled

Best Practices

1. Connection Pooling

Reuse HTTP connections for better performance:

import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry

session = requests.Session()
retry = Retry(
    total=3,
    backoff_factor=0.3,
    status_forcelist=[500, 502, 503, 504]
)
adapter = HTTPAdapter(max_retries=retry)
session.mount('http://', adapter)
session.mount('https://', adapter)

2. Batch Operations

When possible, batch multiple operations:

def batch_create_notes(notes_data):
    """Create multiple notes efficiently"""
    created_notes = []
    
    for data in notes_data:
        note = api.create_note(**data)
        created_notes.append(note)
        
        # Add small delay to avoid overwhelming server
        time.sleep(0.1)
    
    return created_notes

3. Error Handling

Implement robust error handling:

import time
from typing import Callable, Any

def retry_on_error(
    func: Callable,
    max_retries: int = 3,
    backoff_factor: float = 1.0
) -> Any:
    """Retry function with exponential backoff"""
    for attempt in range(max_retries):
        try:
            return func()
        except requests.exceptions.RequestException as e:
            if attempt == max_retries - 1:
                raise
            
            wait_time = backoff_factor * (2 ** attempt)
            print(f"Request failed, retrying in {wait_time}s...")
            time.sleep(wait_time)

# Usage
note = retry_on_error(
    lambda: api.create_note("root", "Title", "Content")
)

4. Caching

Cache frequently accessed data:

from functools import lru_cache
from datetime import datetime, timedelta

class CachedTriliumClient(TriliumETAPI):
    def __init__(self, base_url: str, token: str):
        super().__init__(base_url, token)
        self._cache = {}
        self._cache_times = {}
    
    def get_note_cached(self, note_id: str, max_age: int = 300):
        """Get note with caching (max_age in seconds)"""
        cache_key = f"note:{note_id}"
        
        if cache_key in self._cache:
            cache_time = self._cache_times[cache_key]
            if datetime.now() - cache_time < timedelta(seconds=max_age):
                return self._cache[cache_key]
        
        note = self.get_note(note_id)
        self._cache[cache_key] = note
        self._cache_times[cache_key] = datetime.now()
        
        return note

5. Pagination for Large Results

Handle large result sets with pagination:

def search_all_notes(api: TriliumETAPI, query: str, batch_size: int = 100):
    """Search with pagination for large result sets"""
    all_results = []
    offset = 0
    
    while True:
        results = api.search_notes(
            query,
            limit=batch_size,
            order_by="dateCreated"
        )
        
        if not results:
            break
        
        all_results.extend(results)
        
        if len(results) < batch_size:
            break
        
        # Use the last note's date as reference for next batch
        last_date = results[-1]['dateCreated']
        query_with_date = f"{query} dateCreated>{last_date}"
        
    return all_results

Migration from Internal API

Key Differences

Aspect Internal API ETAPI
Purpose Trilium client communication External integrations
Authentication Session-based Token-based
Stability May change between versions Stable interface
CSRF Protection Required Not required
WebSocket Supported Not available
Documentation Limited Comprehensive

Migration Steps

  1. Replace Authentication

    # Old (Internal API)
    session = requests.Session()
    session.post('/api/login', data={'password': 'pass'})
    
    # New (ETAPI)
    headers = {'Authorization': 'etapi-token'}
    
  2. Update Endpoints

    # Old
    /api/notes/getNoteById/noteId
    
    # New
    /etapi/notes/noteId
    
  3. Adjust Request/Response Format

    # Old (may vary)
    response = session.post('/api/notes/new', json={
        'parentNoteId': 'root',
        'title': 'Title'
    })
    
    # New (standardized)
    response = requests.post('/etapi/create-note', 
        headers=headers,
        json={
            'parentNoteId': 'root',
            'title': 'Title',
            'type': 'text',
            'content': ''
        }
    )
    

Error Handling

Common Error Codes

Status Code Description Resolution
400 BAD_REQUEST Invalid request format Check request body and parameters
401 UNAUTHORIZED Invalid or missing token Verify authentication token
404 NOTE_NOT_FOUND Note doesn't exist Check note ID
404 BRANCH_NOT_FOUND Branch doesn't exist Verify branch ID
400 NOTE_IS_PROTECTED Cannot modify protected note Unlock protected session first
429 TOO_MANY_REQUESTS Rate limit exceeded Wait before retrying
500 INTERNAL_ERROR Server error Report issue, check logs

Error Response Format

{
  "status": 400,
  "code": "VALIDATION_ERROR",
  "message": "Note title cannot be empty"
}

Handling Errors in Code

class ETAPIError(Exception):
    def __init__(self, status, code, message):
        self.status = status
        self.code = code
        self.message = message
        super().__init__(f"{code}: {message}")

def handle_api_response(response):
    if response.status_code >= 400:
        try:
            error = response.json()
            raise ETAPIError(
                error.get('status'),
                error.get('code'),
                error.get('message')
            )
        except json.JSONDecodeError:
            raise ETAPIError(
                response.status_code,
                'UNKNOWN_ERROR',
                response.text
            )
    
    return response.json() if response.content else None

# Usage
try:
    response = requests.get(
        'http://localhost:8080/etapi/notes/invalid',
        headers={'Authorization': 'token'}
    )
    note = handle_api_response(response)
except ETAPIError as e:
    if e.code == 'NOTE_NOT_FOUND':
        print("Note doesn't exist")
    else:
        print(f"API Error: {e.message}")

Performance Considerations

1. Minimize API Calls

# Bad: Multiple calls
note = api.get_note(note_id)
for child_id in note['childNoteIds']:
    child = api.get_note(child_id)  # N+1 problem
    process(child)

# Good: Batch processing
note = api.get_note(note_id)
children = api.search_notes(
    f"note.parents.noteId={note_id}",
    limit=1000
)
for child in children:
    process(child)

2. Use Appropriate Search Depth

# Limit search depth for better performance
results = api.search_notes(
    "keyword",
    ancestor_note_id="root",
    ancestor_depth="lt3"  # Only search 3 levels deep
)

3. Content Compression

Enable gzip compression for large responses:

session = requests.Session()
session.headers.update({
    'Authorization': 'token',
    'Accept-Encoding': 'gzip, deflate'
})

4. Async Operations

Use async requests for parallel operations:

import asyncio
import aiohttp

class AsyncTriliumClient:
    def __init__(self, base_url: str, token: str):
        self.base_url = base_url
        self.headers = {'Authorization': token}
    
    async def get_note(self, session, note_id):
        url = f"{self.base_url}/notes/{note_id}"
        async with session.get(url, headers=self.headers) as response:
            return await response.json()
    
    async def get_multiple_notes(self, note_ids):
        async with aiohttp.ClientSession() as session:
            tasks = [self.get_note(session, nid) for nid in note_ids]
            return await asyncio.gather(*tasks)

# Usage
client = AsyncTriliumClient("http://localhost:8080/etapi", "token")
notes = asyncio.run(client.get_multiple_notes(['id1', 'id2', 'id3']))

5. Database Optimization

For bulk operations, consider:

  • Creating notes in batches
  • Using transactions (via backup/restore)
  • Indexing frequently searched attributes

Security Considerations

Token Management

  • Store tokens securely (environment variables, key vaults)
  • Rotate tokens regularly
  • Use separate tokens for different applications
  • Never commit tokens to version control
import os
from dotenv import load_dotenv

load_dotenv()

# Load token from environment
TOKEN = os.getenv('TRILIUM_ETAPI_TOKEN')
if not TOKEN:
    raise ValueError("TRILIUM_ETAPI_TOKEN not set")

api = TriliumETAPI("http://localhost:8080/etapi", TOKEN)

HTTPS Usage

Always use HTTPS in production:

# Development
dev_api = TriliumETAPI("http://localhost:8080/etapi", token)

# Production
prod_api = TriliumETAPI("https://notes.example.com/etapi", token)

Input Validation

Sanitize user input before sending to API:

import html
import re

def sanitize_html(content: str) -> str:
    """Basic HTML sanitization"""
    # Remove script tags
    content = re.sub(r'<script[^>]*>.*?</script>', '', content, flags=re.DOTALL)
    # Remove on* attributes
    content = re.sub(r'\s*on\w+\s*=\s*["\'][^"\']*["\']', '', content)
    return content

def create_safe_note(title: str, content: str):
    safe_title = html.escape(title)
    safe_content = sanitize_html(content)
    
    return api.create_note(
        parent_note_id="root",
        title=safe_title,
        content=safe_content
    )

Troubleshooting

Connection Issues

# Test connection
def test_connection(base_url, token):
    try:
        api = TriliumETAPI(base_url, token)
        info = api.get_app_info()
        print(f"Connected to Trilium {info['appVersion']}")
        return True
    except Exception as e:
        print(f"Connection failed: {e}")
        return False

# Debug mode
import logging
logging.basicConfig(level=logging.DEBUG)

Common Issues and Solutions

Issue Cause Solution
401 Unauthorized Invalid token Regenerate token in Trilium Options
Connection refused Server not running Start Trilium server
CORS errors Cross-origin requests Configure CORS in Trilium settings
Timeout errors Large operations Increase timeout, use async
404 Not Found Wrong endpoint Check ETAPI prefix in URL
Protected note error Note is encrypted Enter protected session first

Additional Resources