writing-skills
writing-skills
Use when creating new skills, editing existing skills, or verifying skills work before deployment
netlify-apiIntegrates 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.
Independent GitHub snapshot · Automated classification · Original documentation governs
name: netlify-api description: 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. license: MIT compatibility: opencode metadata: version: "1.0.0" domain: coding triggers: netlify api, netlify python, netlify deployments, netlify functions, netlify forms, netlify sites, how do i use netlify api from python archetypes:
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.
https://api.netlify.com/api/v1 with Bearer token authPOST /api/v1/sites/{site_id}/deploys with ZIP body for simple deploysPOST /api/v1/hooks to create build hooks for external deploy triggersnetlify-python community SDK for higher-level abstractionsUse this skill when:
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.
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.
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}.
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"])
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)
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")
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 — 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 — 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
"ready" before announcing deployment completionready stateWhen implementing a Netlify API integration, structure your output as:
| 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) |