REST API Basics for Self-Hosting

What Is an API?

An API (Application Programming Interface) is a structured way for programs to talk to each other. Every serious self-hosted application exposes a REST API — Nextcloud, Gitea, Home Assistant, Jellyfin, Vaultwarden, and dozens more. Understanding REST API basics is the skill that unlocks automation between your self-hosted services: trigger a Home Assistant scene when your monitoring detects downtime, create Gitea issues from n8n workflows, or bulk-manage Nextcloud users from a script.

An API turns your collection of self-hosted apps into a programmable platform. Without it, you’re clicking through web UIs one at a time. With it, you can orchestrate everything.

Prerequisites

  • A Linux server with at least one self-hosted app running (Getting Started)
  • Basic terminal knowledge (Linux Basics)
  • curl installed (ships with every major Linux distro)
  • Python 3.8+ installed (optional, for Python examples)
  • Docker and Docker Compose if following along with the app examples (Docker Compose Basics)

REST API Fundamentals

REST (Representational State Transfer) is an architectural style, not a protocol. A REST API uses standard HTTP to perform operations on resources identified by URLs. You already use REST every time you open a web page — your browser sends an HTTP GET request, the server returns HTML. APIs work the same way, but they return structured data (usually JSON) instead of HTML.

HTTP Methods

REST APIs use HTTP methods to define what you want to do with a resource:

MethodPurposeExampleIdempotent
GETRead a resourceFetch user profileYes
POSTCreate a new resourceCreate a new repositoryNo
PUTReplace a resource entirelyUpdate an entire configYes
PATCHPartially update a resourceChange just a user’s emailNo
DELETERemove a resourceDelete a webhookYes

Idempotent means calling the same request multiple times produces the same result. GET, PUT, and DELETE are safe to retry. POST is not — retrying a POST might create duplicate resources.

In practice, most of your API work will be GET (reading data) and POST (creating things or triggering actions).

Status Codes

Every API response includes an HTTP status code. Memorize these groups:

RangeMeaningCommon Codes
2xxSuccess200 OK, 201 Created, 204 No Content
3xxRedirect301 Moved Permanently, 304 Not Modified
4xxClient error (your fault)400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 429 Too Many Requests
5xxServer error (their fault)500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable

The ones you’ll hit most often:

  • 200 — everything worked, here’s your data.
  • 201 — resource created successfully.
  • 401 — your authentication is wrong or missing.
  • 403 — you authenticated, but don’t have permission.
  • 404 — the endpoint or resource doesn’t exist.
  • 429 — you’re sending too many requests. Slow down.
  • 500 — something broke on the server. Check the app’s logs.

Headers

HTTP headers carry metadata about the request and response. The headers you’ll use constantly with APIs:

Content-Type: application/json      # Tells the server you're sending JSON
Accept: application/json            # Tells the server you want JSON back
Authorization: Bearer <token>       # Authentication token

Headers are key-value pairs sent with every request. Most API errors come from missing or wrong headers — especially Content-Type and Authorization.

JSON

JSON (JavaScript Object Notation) is the standard data format for REST APIs. It looks like this:

{
  "id": 42,
  "name": "my-project",
  "private": false,
  "owner": {
    "login": "admin",
    "email": "[email protected]"
  },
  "tags": ["self-hosted", "automation"]
}

JSON uses:

  • Objects{} containing key-value pairs
  • Arrays[] containing ordered values
  • Strings"double quotes only"
  • Numbers42, 3.14 (no quotes)
  • Booleanstrue or false (no quotes)
  • Nullnull (no quotes)

Single quotes are not valid JSON. Trailing commas are not valid JSON. These are the two most common mistakes.

Authentication

APIs need to know who you are. Self-hosted apps use several authentication methods.

API Keys

The simplest approach. The app generates a static token, and you include it in every request. Home Assistant and many self-hosted apps use this pattern.

# API key in a header
curl -H "X-API-Key: your_api_key_here" \
  https://your-app.example.com/api/resource

# API key as a query parameter (less secure — visible in logs)
curl "https://your-app.example.com/api/resource?apikey=your_api_key_here"

Prefer sending API keys in headers, not query parameters. Query parameters show up in server access logs, browser history, and proxy logs.

Bearer Tokens

Bearer tokens work like API keys but follow a standardized format. You include them in the Authorization header with the Bearer prefix. Gitea, Nextcloud, and most modern apps use this.

curl -H "Authorization: Bearer your_token_here" \
  https://gitea.example.com/api/v1/user

Some apps issue bearer tokens through a login endpoint — you POST your credentials and get back a token:

# Get a token by authenticating
curl -X POST https://your-app.example.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "your_password"}'

# Response contains the token
# {"token": "eyJhbGciOiJIUzI1NiIs..."}

Basic Authentication

HTTP Basic Auth encodes your username and password in a header. It’s the oldest method and still used by some APIs (including Nextcloud’s OCS API).

# curl handles Basic Auth with the -u flag
curl -u "admin:your_password" \
  https://nextcloud.example.com/ocs/v2.php/cloud/users?format=json

Basic Auth sends credentials with every request. Always use it over HTTPS — never over plain HTTP, or your password travels in cleartext. See SSL Certificates for setting up HTTPS.

OAuth 2.0 Basics

OAuth is a framework where users grant limited access to their account without sharing their password. It’s overkill for most self-hosting automation, but some apps (like Gitea) support it for third-party integrations.

The simplified flow:

  1. Register your application with the service (get a client_id and client_secret)
  2. Redirect the user to the service’s authorization page
  3. User approves access
  4. The service redirects back with an authorization code
  5. Exchange the code for an access token
  6. Use the access token like a bearer token

For self-hosting automation, you almost never need full OAuth. Use API keys or personal access tokens instead. Reserve OAuth for when you’re building an integration that other users will authenticate with.

Using APIs with curl

curl is the essential tool for API work. It ships with every Linux distro, requires no installation, and works everywhere. Learn curl and you can interact with any API from any server.

GET — Read Data

# Fetch your Gitea user profile
curl -s \
  -H "Authorization: Bearer your_gitea_token" \
  https://gitea.example.com/api/v1/user

The -s flag suppresses the progress bar. The response is JSON printed to stdout.

POST — Create Resources

# Create a new repository in Gitea
curl -s -X POST \
  -H "Authorization: Bearer your_gitea_token" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-new-repo",
    "description": "Created via API",
    "private": true,
    "auto_init": true
  }' \
  https://gitea.example.com/api/v1/user/repos

-X POST sets the method. -d sends the request body. -H "Content-Type: application/json" tells the server you’re sending JSON.

PUT / PATCH — Update Resources

# Update a Gitea repository description (PATCH = partial update)
curl -s -X PATCH \
  -H "Authorization: Bearer your_gitea_token" \
  -H "Content-Type: application/json" \
  -d '{"description": "Updated via API"}' \
  https://gitea.example.com/api/v1/repos/admin/my-new-repo

DELETE — Remove Resources

# Delete a repository
curl -s -X DELETE \
  -H "Authorization: Bearer your_gitea_token" \
  https://gitea.example.com/api/v1/repos/admin/my-new-repo

DELETE requests usually return 204 No Content on success — no response body.

Useful curl Flags

FlagPurpose
-sSilent mode (no progress bar)
-SShow errors even in silent mode
-fFail silently on HTTP errors (useful in scripts)
-o fileWrite output to a file instead of stdout
-iInclude response headers in output
-vVerbose — shows the full request and response, including headers
-w '\n'Add a newline after the response (JSON doesn’t always end with one)

Formatting JSON Output

Raw JSON from curl is a single line. Pipe it through jq to make it readable:

# Pretty-print JSON
curl -s -H "Authorization: Bearer token" \
  https://gitea.example.com/api/v1/user | jq .

# Extract a specific field
curl -s -H "Authorization: Bearer token" \
  https://gitea.example.com/api/v1/user | jq '.login'

# Extract from an array
curl -s -H "Authorization: Bearer token" \
  https://gitea.example.com/api/v1/user/repos | jq '.[].name'

Install jq if you don’t have it:

sudo apt install -y jq

jq is indispensable for API scripting. Learn its basics — you’ll use it constantly.

Using APIs with Python

For anything more complex than a single curl command — loops, error handling, chaining multiple API calls — use Python with the requests library.

Install requests

pip3 install requests

GET Example

import requests

GITEA_URL = "https://gitea.example.com"
TOKEN = "your_gitea_token"

headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Accept": "application/json",
}

response = requests.get(f"{GITEA_URL}/api/v1/user", headers=headers)

if response.status_code == 200:
    user = response.json()
    print(f"Logged in as: {user['login']}")
    print(f"Email: {user['email']}")
else:
    print(f"Error {response.status_code}: {response.text}")

POST Example

import requests

GITEA_URL = "https://gitea.example.com"
TOKEN = "your_gitea_token"

headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

repo_data = {
    "name": "automated-repo",
    "description": "Created by Python script",
    "private": True,
    "auto_init": True,
}

response = requests.post(
    f"{GITEA_URL}/api/v1/user/repos",
    headers=headers,
    json=repo_data,  # requests handles JSON serialization
)

if response.status_code == 201:
    repo = response.json()
    print(f"Created: {repo['html_url']}")
else:
    print(f"Error {response.status_code}: {response.text}")

Use json= instead of data= when sending JSON — requests automatically serializes the dict and sets the Content-Type header.

Error Handling Pattern

import requests
from requests.exceptions import ConnectionError, Timeout

def api_request(method, url, headers, json=None, timeout=10):
    """Make an API request with proper error handling."""
    try:
        response = requests.request(
            method, url, headers=headers, json=json, timeout=timeout
        )
        response.raise_for_status()  # Raises exception for 4xx/5xx
        return response.json() if response.content else None
    except ConnectionError:
        print(f"Cannot connect to {url} — is the service running?")
        return None
    except Timeout:
        print(f"Request to {url} timed out after {timeout}s")
        return None
    except requests.exceptions.HTTPError as e:
        print(f"HTTP {e.response.status_code}: {e.response.text}")
        return None

Always set a timeout. Without it, a hung service will block your script indefinitely.

Self-Hosted Apps with APIs

Here are practical examples against three of the most popular self-hosted apps. These are real endpoints you can use today.

Nextcloud — OCS API

Nextcloud uses the OCS (Open Collaboration Services) API with Basic Auth. Add format=json to get JSON instead of XML.

List all users:

curl -s -u "admin:your_nextcloud_password" \
  -H "OCS-APIRequest: true" \
  "https://nextcloud.example.com/ocs/v2.php/cloud/users?format=json" | jq '.ocs.data.users'

The OCS-APIRequest: true header is required — Nextcloud rejects OCS requests without it.

Create a new user:

curl -s -X POST \
  -u "admin:your_nextcloud_password" \
  -H "OCS-APIRequest: true" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "userid=newuser&password=SecurePass123&[email protected]" \
  "https://nextcloud.example.com/ocs/v2.php/cloud/users?format=json"

Get server status (no auth required):

curl -s https://nextcloud.example.com/status.php | jq .

Gitea — REST API v1

Gitea has a clean REST API. Generate a token at Settings > Applications > Manage Access Tokens.

List your repositories:

curl -s \
  -H "Authorization: Bearer your_gitea_token" \
  https://gitea.example.com/api/v1/user/repos | jq '.[].full_name'

Create an issue:

curl -s -X POST \
  -H "Authorization: Bearer your_gitea_token" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Automated issue from API",
    "body": "This issue was created by a script.",
    "labels": [1]
  }' \
  https://gitea.example.com/api/v1/repos/admin/my-repo/issues

Search repositories:

curl -s \
  -H "Authorization: Bearer your_gitea_token" \
  "https://gitea.example.com/api/v1/repos/search?q=docker&limit=5" | jq '.data[].full_name'

Gitea also publishes an OpenAPI/Swagger spec at /api/swagger — open it in your browser for the full endpoint reference.

Home Assistant — REST API

Home Assistant uses long-lived access tokens. Generate one at Profile > Long-Lived Access Tokens.

Get all entity states:

curl -s \
  -H "Authorization: Bearer your_ha_token" \
  -H "Content-Type: application/json" \
  https://homeassistant.example.com:8123/api/states | jq '.[].entity_id'

Get a specific entity state:

curl -s \
  -H "Authorization: Bearer your_ha_token" \
  https://homeassistant.example.com:8123/api/states/sensor.living_room_temperature | jq '{state: .state, unit: .attributes.unit_of_measurement}'

Call a service (turn on a light):

curl -s -X POST \
  -H "Authorization: Bearer your_ha_token" \
  -H "Content-Type: application/json" \
  -d '{"entity_id": "light.living_room"}' \
  https://homeassistant.example.com:8123/api/services/light/turn_on

Fire an event:

curl -s -X POST \
  -H "Authorization: Bearer your_ha_token" \
  -H "Content-Type: application/json" \
  -d '{"message": "Backup completed"}' \
  https://homeassistant.example.com:8123/api/events/custom_backup_done

Home Assistant’s API is one of the most powerful in the self-hosting ecosystem. Combined with its automation engine, you can build complex workflows entirely through API calls.

API Rate Limiting

Most APIs limit how many requests you can make in a given time window. This protects the server from being overwhelmed — whether by a bug in your script or a deliberate attack.

How Rate Limiting Works

The server tracks your requests (usually by IP or API token) and returns headers telling you your limits:

X-RateLimit-Limit: 100        # Max requests per window
X-RateLimit-Remaining: 47     # Requests left in current window
X-RateLimit-Reset: 1708300800 # Unix timestamp when the window resets

When you exceed the limit, you get a 429 Too Many Requests response. Your script should handle this:

import requests
import time

def rate_limited_request(url, headers, max_retries=3):
    """Make a request with automatic retry on rate limit."""
    for attempt in range(max_retries):
        response = requests.get(url, headers=headers, timeout=10)

        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 60))
            print(f"Rate limited. Waiting {retry_after}s...")
            time.sleep(retry_after)
            continue

        return response

    raise Exception(f"Still rate limited after {max_retries} retries")

Rate Limiting Best Practices

  • Add delays between bulk operations. If creating 100 users, add a time.sleep(0.5) between each request.
  • Cache GET responses. Don’t fetch the same data repeatedly. Store it locally.
  • Use pagination. Fetch large lists in pages instead of all at once.
  • Read the API docs. Each app documents its specific limits (or lack thereof). Self-hosted apps often have no rate limiting at all — but that doesn’t mean you should hammer them with 1,000 requests per second.

Webhooks

Webhooks are the reverse of a normal API call. Instead of you asking the app for data (polling), the app sends data to your URL when something happens. Webhooks are event-driven — they fire immediately when triggered, with zero delay.

How Webhooks Work

  1. You set up an HTTP endpoint that can receive POST requests (a webhook receiver)
  2. You register that URL in the app’s webhook settings
  3. When an event occurs (push to repo, new user created, sensor state change), the app sends a POST request to your URL with event details in the JSON body

Gitea Webhook Example

In Gitea, go to Repository Settings > Webhooks > Add Webhook.

Configure:

  • Target URL: https://your-automation.example.com/webhook/gitea
  • HTTP Method: POST
  • Content Type: application/json
  • Secret: a shared secret for verifying the webhook is genuine
  • Trigger Events: push, pull request, issue, etc.

Gitea sends a payload like this on every push:

{
  "ref": "refs/heads/main",
  "before": "abc123...",
  "after": "def456...",
  "repository": {
    "full_name": "admin/my-repo"
  },
  "pusher": {
    "login": "admin"
  },
  "commits": [
    {
      "message": "Fix container restart policy",
      "url": "https://gitea.example.com/admin/my-repo/commit/def456"
    }
  ]
}

Receiving Webhooks

A minimal Python webhook receiver:

from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import hmac
import hashlib

WEBHOOK_SECRET = "your_shared_secret"

class WebhookHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)

        # Verify the webhook signature (Gitea uses X-Gitea-Signature)
        signature = self.headers.get("X-Gitea-Signature", "")
        expected = hmac.new(
            WEBHOOK_SECRET.encode(), body, hashlib.sha256
        ).hexdigest()

        if not hmac.compare_digest(signature, expected):
            self.send_response(403)
            self.end_headers()
            return

        payload = json.loads(body)
        print(f"Push to {payload['repository']['full_name']}")
        print(f"By: {payload['pusher']['login']}")

        self.send_response(200)
        self.end_headers()

server = HTTPServer(("0.0.0.0", 9000), WebhookHandler)
print("Webhook receiver listening on port 9000")
server.serve_forever()

Always verify webhook signatures. Without verification, anyone who discovers your webhook URL can send fake events. The shared secret ensures only the legitimate source can trigger your receiver.

For production use, run webhook receivers behind your reverse proxy with HTTPS. Tools like n8n and Huginn provide webhook receivers with visual workflow builders — far better than rolling your own for complex automation chains.

Common Mistakes

Forgetting Content-Type Header

Sending JSON without Content-Type: application/json is the number one API mistake. The server receives your data but doesn’t know how to parse it, returning a 400 Bad Request or silently ignoring the body.

# Wrong — missing Content-Type
curl -X POST -d '{"name": "test"}' https://api.example.com/resource

# Correct
curl -X POST -H "Content-Type: application/json" -d '{"name": "test"}' https://api.example.com/resource

Using HTTP Instead of HTTPS

API tokens sent over plain HTTP are visible to anyone on the network. Always use HTTPS, even on your local network. Self-signed certificates or a proper reverse proxy with Let’s Encrypt solve this. See SSL Certificates for setup.

Hardcoding Credentials in Scripts

Never put API tokens directly in script files that get committed to Git:

# Wrong
TOKEN = "ghp_abc123def456"

# Right — use environment variables
import os
TOKEN = os.environ["GITEA_TOKEN"]

Store credentials in environment variables or a .env file excluded from version control. See Docker Environment Variables for patterns.

Not Handling Errors

Assuming every request succeeds will burn you. Network issues, service restarts, and auth token expiry all happen. Always check status codes:

response = requests.get(url, headers=headers, timeout=10)
if response.status_code != 200:
    print(f"Failed: {response.status_code}{response.text}")
    # Handle the error, don't just continue

Invalid JSON

Single quotes, trailing commas, and unquoted keys are not valid JSON:

# Wrong (single quotes)
curl -d "{'name': 'test'}" ...

# Wrong (trailing comma)
curl -d '{"name": "test",}' ...

# Correct
curl -d '{"name": "test"}' ...

Use jq to validate JSON before sending: echo '{"name": "test"}' | jq .

Next Steps

  • Automate with n8n. If you want visual workflow automation between your self-hosted apps, n8n connects to hundreds of APIs with a drag-and-drop interface.
  • Explore your apps’ API docs. Most self-hosted apps publish API documentation — Gitea at /api/swagger, Home Assistant at /api/, Nextcloud at the developer docs site.
  • Build a monitoring script. Use what you learned here to write a script that checks the health of all your services via their APIs and sends alerts through SMTP.
  • Secure your API endpoints. Restrict API access with firewall rules and strong tokens. See the Self-Hosting Security Checklist.
  • Learn about ports and networking. Understanding how your services expose APIs on the network is critical. See Ports Explained.

FAQ

Do all self-hosted apps have APIs?

Most mature self-hosted apps expose a REST API, but not all. Apps like Pi-hole, Uptime Kuma, and Portainer all have APIs. Smaller or newer apps might not. Check the project’s documentation or GitHub repository — look for an api/ directory, Swagger/OpenAPI specs, or API documentation pages. If there’s no API, you can sometimes interact with the app’s database directly, but that’s fragile and not recommended.

Should I use curl or Python for API automation?

Use curl for one-off requests, quick testing, and simple scripts. Use Python (with the requests library) when you need loops, error handling, conditional logic, or chaining multiple API calls together. A good rule: if your shell script has more than 10 lines of curl commands, rewrite it in Python.

How do I find the right API endpoint for what I want to do?

Start with the app’s official documentation. Many apps publish interactive API docs — Gitea has Swagger at /api/swagger, Home Assistant documents everything at developers.home-assistant.io. If docs are sparse, check the app’s source code on GitHub for route definitions. You can also use your browser’s developer tools (Network tab) to see which API calls the web UI makes when you perform an action — then replicate those calls with curl.

Is it safe to expose self-hosted APIs to the internet?

Only behind HTTPS with proper authentication. Never expose an API endpoint over plain HTTP. Use a reverse proxy with SSL termination, require strong API tokens, and consider restricting access by IP or through a VPN like Tailscale or WireGuard. For internal-only automation, keep APIs on your local network and use Cloudflare Tunnel or Tailscale for remote access instead of opening ports.

What’s the difference between an API key and a bearer token?

Functionally, they’re similar — both are strings you send with requests to authenticate. The difference is mostly in convention and management. API keys are typically long-lived, generated in an app’s settings page, and sent in a custom header (like X-API-Key). Bearer tokens follow the OAuth standard, are sent in the Authorization: Bearer <token> header, and may be short-lived with refresh mechanisms. For self-hosting automation, the distinction rarely matters — use whichever the app provides.