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/v1with Bearer token auth - Use the file digest method for deploys (SHA1 for files, SHA256 for functions)
- Use
POST /api/v1/sites/{site_id}/deployswith ZIP body for simple deploys - Use
POST /api/v1/hooksto create build hooks for external deploy triggers - Handle HTTP 401 (auth), 404 (not found), 422 (validation) errors explicitly
- Use
netlify-pythoncommunity 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.
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
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.
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)
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
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
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
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
# ❌ 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
# ❌ 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
readystate - 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:
- Client Initialization — HTTP client with Bearer token auth
- Site Management — Create, list, get, update, delete sites
- Deploy — File digest method: POST manifest → PUT missing files → poll for ready
- Functions — ZIP function code → PUT to deploy → poll completion
- Build Hooks — Create hooks, trigger via webhook URL (no auth)
- Environment Variables — Set/list env vars per site
- 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 — Official API getting started guide
- Netlify OpenAPI Spec — Complete API reference
- Netlify Python SDK (netlify-python) — Community Python client library
- Netlify Functions API Reference — Serverless function deployment API
- Netlify REST API Reference (Unofficial) — Curated API endpoint reference
- Netlify CLI Reference — Complementary CLI tool
- Netlify Deploy API Guide — Deploy workflow details