# Netlify API

> Integrates Netlify services (Sites, Builds, Functions, Forms, Identity) using the Netlify REST API with Python, covering token-based authentication, site management, deployment workflows, and serverless function deployment.

- Skill: `paulpas/netlify-api` (Agent Skill)
- Install (CLI): `npx skillmds@latest add paulpas/netlify-api`
- Raw SKILL.md: https://api.skillmd.com/api/skills/paulpas/netlify-api/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: paulpas (https://skillmd.com/u/paulpas)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/paulpas/netlify-api

---





# Netlify API Integration Patterns

Integrates Netlify services using the Netlify REST API (`api.netlify.com/api/v1`) with Python. Covers Personal Access Token authentication, site creation and management, atomic deploys (file digest and ZIP methods), serverless function deployment, form submission handling, and build hook management patterns.

## TL;DR Checklist

- [ ] Use Netlify Personal Access Tokens from User Settings → Applications
- [ ] Use the REST API at `https://api.netlify.com/api/v1` with Bearer token auth
- [ ] Use the file digest method for deploys (SHA1 for files, SHA256 for functions)
- [ ] Use `POST /api/v1/sites/{site_id}/deploys` with ZIP body for simple deploys
- [ ] Use `POST /api/v1/hooks` to create build hooks for external deploy triggers
- [ ] Handle HTTP 401 (auth), 404 (not found), 422 (validation) errors explicitly
- [ ] Use `netlify-python` community SDK for higher-level abstractions

---

## When to Use

Use this skill when:

- Automating site creation and deployment from CI/CD pipelines
- Deploying serverless functions alongside static sites programmatically
- Managing multiple Netlify sites across teams from Python scripts
- Building custom deployment workflows that bypass Git integration
- Creating build hooks for external deployment triggers (webhooks)
- Handling form submissions and managing form data via API
- Automating site configuration (environment variables, redirects, headers)

---

## When NOT to Use

- For standard Git-connected sites (use Netlify's automatic Git integration)
- For one-off manual operations (use Netlify CLI or Dashboard)
- For large-scale infrastructure-as-code (use Terraform or Netlify's team settings)
- When you need real-time deploy logs (use Netlify Dashboard or CLI)

---

## Core Workflow

### 1. Authentication and API Client

Netlify REST API uses Bearer token authentication with Personal Access Tokens.

```python
import os
import httpx
from httpx import HTTPStatusError


class NetlifyClient:
    """HTTP client for the Netlify REST API."""

    BASE_URL = "https://api.netlify.com/api/v1"

    def __init__(self, token: str | None = None):
        self.token = token or os.environ.get("NETLIFY_TOKEN")
        if not self.token:
            raise RuntimeError(
                "Set NETLIFY_TOKEN environment variable with your PAT"
            )
        self.headers = {
            "Authorization": f"Bearer {self.token}",
            "Content-Type": "application/json",
        }

    def _request(self, method: str, path: str, **kwargs) -> dict | list:
        """Make an API request with error handling."""
        url = f"{self.BASE_URL}{path}"
        with httpx.Client() as client:
            try:
                response = client.request(
                    method=method,
                    url=url,
                    headers=self.headers,
                    **kwargs,
                )
                response.raise_for_status()
                return response.json()
            except HTTPStatusError as err:
                if err.response.status_code == 401:
                    raise PermissionError(
                        "Invalid or expired Netlify PAT"
                    ) from err
                if err.response.status_code == 404:
                    raise ValueError(
                        f"Resource not found: {path}"
                    ) from err
                if err.response.status_code == 422:
                    raise ValueError(
                        f"Validation error: {err.response.text}"
                    ) from err
                raise
```

**Checkpoint:** Create a PAT at User Settings → Applications → Personal Access Tokens. Test with `GET /sites`. A successful response returns a list of sites.

### 2. Site Management

```python
class SiteManager(NetlifyClient):
    """Manage Netlify sites."""

    def list_sites(self) -> list[dict]:
        """List all sites accessible to the token."""
        result = self._request("GET", "/sites")
        return result if isinstance(result, list) else []

    def get_site(self, site_id: str) -> dict:
        """Get site details by ID."""
        try:
            return self._request("GET", f"/sites/{site_id}")
        except ValueError:
            raise FileNotFoundError(f"Site '{site_id}' not found") from None

    def create_site(
        self,
        name: str,
        custom_domain: str | None = None,
        password: str | None = None,
    ) -> dict:
        """Create a new Netlify site."""
        payload: dict = {"name": name}
        if custom_domain:
            payload["custom_domain"] = custom_domain
        if password:
            payload["password"] = password

        return self._request("POST", "/sites", json=payload)

    def create_site_in_team(self, account_slug: str, name: str) -> dict:
        """Create a site in a specific team account."""
        return self._request(
            "POST",
            f"/{account_slug}/sites",
            json={"name": name},
        )

    def update_site(self, site_id: str, updates: dict) -> dict:
        """Update site configuration."""
        return self._request("PATCH", f"/sites/{site_id}", json=updates)

    def delete_site(self, site_id: str) -> None:
        """Delete a site."""
        try:
            self._request("DELETE", f"/sites/{site_id}")
        except ValueError:
            pass  # Already deleted — idempotent
```

**Checkpoint:** Site names are used for the Netlify subdomain (`{name}.netlify.app`). They must be globally unique across all Netlify users. Set `force_ssl: true` in updates to enable HTTPS.

### 3. Atomic Deploy (File Digest Method)

The file digest method uploads a manifest of files, then only uploads files Netlify doesn't already have.

```python
import hashlib
import os


class DeployManager(NetlifyClient):
    """Manage Netlify deployments."""

    def _hash_file(self, filepath: str) -> str:
        """Compute SHA1 hash (files) or SHA256 (functions) for deploy digest."""
        sha = hashlib.sha1()
        with open(filepath, "rb") as f:
            for chunk in iter(lambda: f.read(8192), b""):
                sha.update(chunk)
        return sha.hexdigest()

    def _build_file_digest(self, directory: str) -> list[dict]:
        """Build a file digest manifest for a directory."""
        files = []
        for root, dirs, filenames in os.walk(directory):
            for filename in filenames:
                filepath = os.path.join(root, filename)
                relpath = os.path.relpath(filepath, directory)
                files.append({
                    "path": relpath,
                    "sha1": self._hash_file(filepath),
                    "size": os.path.getsize(filepath),
                })
        return files

    def create_deploy(self, site_id: str, directory: str) -> dict:
        """Create a deploy using file digest method."""
        files = self._build_file_digest(directory)

        # Step 1: Send the digest — Netlify responds with required uploads
        result = self._request(
            "POST",
            f"/sites/{site_id}/deploys",
            json={"files": {f["path"]: f["sha1"] for f in files}},
        )

        deploy_id = result["id"]
        required = result.get("required", [])

        # Step 2: Upload files that Netlify doesn't have
        if required:
            for file_entry in files:
                if file_entry["path"] in required:
                    self._upload_deploy_file(
                        deploy_id, file_entry["path"], directory
                    )

        # Step 3: Wait for deploy to complete
        return self._wait_for_deploy(deploy_id)

    def _upload_deploy_file(
        self, deploy_id: str, file_path: str, base_dir: str
    ) -> None:
        """Upload a single file to a pending deploy."""
        full_path = os.path.join(base_dir, file_path)
        url = f"/deploys/{deploy_id}/files/{file_path}"

        with httpx.Client() as client:
            with open(full_path, "rb") as f:
                response = client.put(
                    f"{self.BASE_URL}{url}",
                    headers={"Authorization": f"Bearer {self.token}"},
                    content=f.read(),
                )
                response.raise_for_status()

    def _wait_for_deploy(
        self, deploy_id: str, timeout: int = 300
    ) -> dict:
        """Poll deploy until it's ready."""
        import time

        start = time.time()
        while time.time() - start < timeout:
            deploy = self._request("GET", f"/deploys/{deploy_id}")
            state = deploy.get("state")
            if state == "ready":
                return deploy
            if state in ("error", "canceled"):
                raise RuntimeError(
                    f"Deploy {deploy_id} failed with state: {state}"
                )
            time.sleep(3)

        raise TimeoutError(f"Deploy {deploy_id} not ready in {timeout}s")

    def list_deploys(self, site_id: str) -> list[dict]:
        """List all deploys for a site."""
        result = self._request("GET", f"/sites/{site_id}/deploys")
        return result if isinstance(result, list) else []
```

**Checkpoint:** The file digest method requires two API calls: first `POST` the manifest, then `PUT` only the required files. Functions use SHA256 instead of SHA1. Upload function files to `/deploys/{id}/functions/{name}`.

### 4. ZIP Deploy Method (Simpler Alternative)

```python
def deploy_zip(self, site_id: str, zip_path: str) -> dict:
    """Deploy a ZIP file containing the entire site."""
    url = f"{self.BASE_URL}/sites/{site_id}/deploys"
    with httpx.Client() as client:
        with open(zip_path, "rb") as f:
            response = client.post(
                url,
                headers={
                    "Authorization": f"Bearer {self.token}",
                    "Content-Type": "application/zip",
                },
                content=f.read(),
            )
            response.raise_for_status()
            return self._wait_for_deploy(response.json()["id"])
```

---

## Implementation Patterns

### Pattern 1: Serverless Function Deployment

```python
import zipfile
from io import BytesIO


class FunctionsManager(NetlifyClient):
    """Deploy Netlify Functions."""

    def deploy_function(
        self, site_id: str, function_name: str, source_code: str
    ) -> dict:
        """Deploy a JavaScript/Python serverless function."""
        # Step 1: Create a pending deploy
        deploy = self._request(
            "POST",
            f"/sites/{site_id}/deploys",
            json={"files": {}},  # No static files, just functions
        )
        deploy_id = deploy["id"]

        # Step 2: Create function as ZIP
        zip_buffer = BytesIO()
        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr(
                f"{function_name}.js",
                source_code,
            )
        zip_buffer.seek(0)

        # Step 3: Upload function
        url = f"{self.BASE_URL}/deploys/{deploy_id}/functions/{function_name}"
        with httpx.Client() as client:
            response = client.put(
                url,
                headers={
                    "Authorization": f"Bearer {self.token}",
                    "Content-Type": "application/zip",
                },
                content=zip_buffer.getvalue(),
            )
            response.raise_for_status()

        return self._wait_for_deploy(deploy_id)
```

### Pattern 2: Build Hook Trigger

```python
def create_build_hook(
    client: NetlifyClient,
    site_id: str,
    title: str,
    branch: str = "main",
) -> dict:
    """Create a build hook that triggers a deploy."""
    return client._request(
        "POST",
        f"/sites/{site_id}/build_hooks",
        json={"title": title, "branch": branch},
    )


def trigger_build_hook(hook_url: str) -> bool:
    """Trigger a deploy via a build hook URL (no auth needed)."""
    import httpx

    try:
        response = httpx.post(hook_url)
        response.raise_for_status()
        return True
    except httpx.HTTPStatusError as err:
        raise RuntimeError(
            f"Build hook trigger failed: {err.response.status_code}"
        ) from err


def list_build_hooks(client: NetlifyClient, site_id: str) -> list[dict]:
    """List all build hooks for a site."""
    return client._request("GET", f"/sites/{site_id}/build_hooks")
```

### Pattern 3: Environment Variables Management

```python
def set_env_var(
    client: NetlifyClient,
    site_id: str,
    key: str,
    value: str,
    scopes: list[str] | None = None,
) -> dict:
    """Set an environment variable for a site."""
    payload = {
        "key": key,
        "value": value,
    }
    if scopes:
        payload["scopes"] = scopes  # e.g., ["builds", "functions", "runtime"]

    return client._request(
        "POST",
        f"/sites/{site_id}/env",
        json=payload,
    )


def list_env_vars(client: NetlifyClient, site_id: str) -> list[dict]:
    """List all environment variables for a site."""
    return client._request("GET", f"/sites/{site_id}/env")
```

### BAD vs GOOD: File Digest Deployment

```python
# ❌ BAD — Uploads all files every time, even if unchanged
def naive_deploy(site_id: str, directory: str):
    files = {}
    for f in os.listdir(directory):
        with open(os.path.join(directory, f), "rb") as fh:
            files[f] = fh.read()

    requests.post(
        f"https://api.netlify.com/api/v1/sites/{site_id}/deploys",
        headers={"Authorization": "Bearer token"},
        json={"files": files},
    )

# ✅ GOOD — Uses digest to upload only changed files
def efficient_deploy(client: NetlifyClient, site_id: str, directory: str):
    deploy_mgr = DeployManager(token=client.token)
    deploy = deploy_mgr.create_deploy(site_id, directory)
    print(f"Deployed: https://{deploy['ssl_url']}")
```

### BAD vs GOOD: Error Handling

```python
# ❌ BAD — Generic except, no error type distinction
try:
    response = httpx.get(
        "https://api.netlify.com/api/v1/sites",
        headers={"Authorization": "Bearer token"},
    )
except Exception as e:
    print(f"Error: {e}")

# ✅ GOOD — Handle specific HTTP errors with recovery
from httpx import HTTPStatusError

try:
    response = httpx.get(url, headers=headers)
    response.raise_for_status()
except HTTPStatusError as err:
    if err.response.status_code == 401:
        raise PermissionError("Netlify token is invalid or expired") from err
    if err.response.status_code == 404:
        raise FileNotFoundError(f"Site not found") from err
    if err.response.status_code == 422:
        raise ValueError(f"Validation error: {err.response.text}") from err
    raise
```

---

## Constraints

### MUST DO
- Use Personal Access Tokens (PAT) for API authentication — never use OAuth tokens in scripts
- Use the file digest method for deploys (SHA1 for static files, SHA256 for functions) — it only uploads changed files
- Use the ZIP deploy method for simple single-file deployments or first-time deploys
- Poll deploy state until `"ready"` before announcing deployment completion
- Handle HTTP 401 (invalid token), 404 (not found), and 422 (validation) errors specifically
- Store API tokens in environment variables — never hardcode in source

### MUST NOT DO
- Use PATs with more permissions than needed — create scoped tokens for specific operations
- Upload all files on every deploy — always use the digest method for efficiency
- Assume deploys complete instantly — always poll for `ready` state
- Use the same token for personal and team operations without the account slug
- Forget to ZIP serverless functions before uploading — Netlify requires zipped function code

---

## Output Template

When implementing a Netlify API integration, structure your output as:

1. **Client Initialization** — HTTP client with Bearer token auth
2. **Site Management** — Create, list, get, update, delete sites
3. **Deploy** — File digest method: POST manifest → PUT missing files → poll for ready
4. **Functions** — ZIP function code → PUT to deploy → poll completion
5. **Build Hooks** — Create hooks, trigger via webhook URL (no auth)
6. **Environment Variables** — Set/list env vars per site
7. **Error Handling** — HTTP status codes with specific recovery

---

## Related Skills

| Skill | Purpose |
|---|---|
| `vercel-api` | Vercel deployment API (complementary platform) |
| `cloudflare-api` | Cloudflare API for DNS and edge services |
| `aws-sdk` | AWS SDK patterns (Lambda similar to Netlify Functions) |
| `digitalocean-api` | DigitalOcean API patterns (App Platform similar) |

---

## Live References

- [Netlify API Documentation](https://docs.netlify.com/api-and-cli-guides/api-guides/get-started-with-api/) — Official API getting started guide
- [Netlify OpenAPI Spec](https://open-api.netlify.com/) — Complete API reference
- [Netlify Python SDK (netlify-python)](https://github.com/cbrews/netlify-python) — Community Python client library
- [Netlify Functions API Reference](https://docs.netlify.com/build/functions/api/) — Serverless function deployment API
- [Netlify REST API Reference (Unofficial)](https://netlify.rest/) — Curated API endpoint reference
- [Netlify CLI Reference](https://docs.netlify.com/cli/get-started/) — Complementary CLI tool
- [Netlify Deploy API Guide](https://docs.netlify.com/api-and-cli-guides/api-guides/deploy-with-api/) — Deploy workflow details

