Kinsta API Mastery: Automating WordPress Operations for Agencies at Scale
Agencies managing dozens or hundreds of WordPress sites need automation that goes beyond clicking around a hosting dashboard. Kinsta introduced its REST API in 2022, and it changed the game for teams that want programmatic control over site provisioning, environment management, cache operations, and backups. If you are still logging into MyKinsta to clear cache on 40 sites one at a time, this article is your exit ramp.
This guide covers everything an agency DevOps engineer needs to build real automation with the Kinsta API. We will walk through authentication, site creation, bulk operations, staging workflows, DNS management, backup automation, GitHub Actions integration, and production-ready error handling. Every code example is tested against the current API (v2). No hand-waving; just working code and practical patterns.
Kinsta API Fundamentals: Authentication and First Requests
The Kinsta API uses Bearer token authentication. Every request requires an API key passed in the Authorization header. You generate API keys inside MyKinsta under your user settings, not at the company level.
Generating Your API Key
Log into MyKinsta, navigate to your user profile (your name in the top-right corner), click “API Keys,” and generate a new key. Give it a descriptive name like “agency-automation” or “github-actions-prod.” Copy the key immediately because Kinsta will not show it again.
A few critical details about API keys:
- Each key is tied to a specific user account, not a company
- The key inherits the permissions of the user who created it
- Company owners and administrators can create keys with full access
- Company developers get read-only access to most endpoints
- There is no way to scope a key to specific sites (yet)
Store this key in a secrets manager, environment variable, or CI/CD secret store. Never commit it to a repository.
Your First API Call
Let us start with the simplest possible request: listing your company’s sites. This confirms your key works and shows you the data structure you will be working with throughout this guide.
curl -s "https://api.kinsta.com/v2/sites?company=YOUR_COMPANY_ID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" | jq '.'
The response returns a JSON object with a company wrapper containing a sites array. Each site object includes the site’s id, name, display_name, and status.
To find your company ID, hit the companies endpoint first:
curl -s "https://api.kinsta.com/v2/companies" \
-H "Authorization: Bearer YOUR_API_KEY" | jq '.companies[0].id'
Save both your API key and company ID as environment variables. Every script in this article assumes they exist:
export KINSTA_API_KEY="your-api-key-here"
export KINSTA_COMPANY_ID="your-company-id-here"
Understanding the Response Format
Kinsta API responses follow a consistent pattern. Successful requests return a JSON body with the relevant data nested under a descriptive key. Errors return an HTTP status code (400, 401, 403, 404, 429) along with a message field explaining what went wrong.
Many operations are asynchronous. When you create a site or trigger a cache clear, the API returns immediately with an operation ID. You then poll a separate endpoint to check operation status. This is a fundamental pattern you will encounter repeatedly, and your automation code must handle it.
# Example: checking operation status
curl -s "https://api.kinsta.com/v2/operations/OPERATION_ID" \
-H "Authorization: Bearer YOUR_API_KEY" | jq '.status'
The status field will be one of: is_running, has_completed, or has_failed. Build your polling logic around these three states.
Site Provisioning Automation
Creating sites through the API is one of the highest-value automations for agencies. Instead of manually provisioning through MyKinsta every time you onboard a new client, you can trigger site creation from a script, a Slack command, or a client onboarding form.
Creating a New Site
The site creation endpoint accepts a POST request with your site configuration:
curl -s "https://api.kinsta.com/v2/sites" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"company": "'"$KINSTA_COMPANY_ID"'",
"display_name": "Client Acme Corp",
"region": "us-central1",
"install_mode": "new",
"is_subdomain_multisite": false,
"admin_email": "[email protected]",
"admin_password": "a-secure-generated-password",
"admin_user": "acme-admin",
"is_multisite": false,
"site_title": "Acme Corporation",
"woocommerce": false,
"wordpressseo": false,
"wp_language": "en_US"
}'
The region parameter accepts Kinsta’s Google Cloud Platform region codes. Available regions include us-central1, us-east1, europe-west1, europe-west2, europe-west3, australia-southeast1, asia-southeast1, and several others. Pick the region closest to the client’s primary audience.
The install_mode field supports new for a fresh WordPress install or clone if you want to clone an existing environment (useful for templated agency setups).
This request returns an operation ID. Site creation typically takes 1 to 3 minutes, so you need to poll the operation status.
Polling for Operation Completion
Here is a bash function that polls an operation until it completes or fails:
wait_for_operation() {
local operation_id="$1"
local max_attempts=60
local attempt=0
while [ $attempt -lt $max_attempts ]; do
status=$(curl -s "https://api.kinsta.com/v2/operations/$operation_id" \
-H "Authorization: Bearer $KINSTA_API_KEY" | jq -r '.status')
case "$status" in
"is_running")
echo "Operation $operation_id still running... (attempt $attempt)"
sleep 10
;;
"has_completed")
echo "Operation $operation_id completed successfully."
return 0
;;
"has_failed")
echo "Operation $operation_id failed."
return 1
;;
*)
echo "Unknown status: $status"
return 2
;;
esac
attempt=$((attempt + 1))
done
echo "Operation $operation_id timed out after $max_attempts attempts."
return 3
}
Use it like this:
response=$(curl -s "https://api.kinsta.com/v2/sites" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{ ... }')
operation_id=$(echo "$response" | jq -r '.operation_id')
wait_for_operation "$operation_id"
Site Provisioning Script for Agency Onboarding
Here is a more complete PHP script that an agency might use to automate client onboarding. It creates the site, waits for completion, and retrieves the new site’s details:
<?php
class KinstaSiteProvisioner {
private string $api_key;
private string $company_id;
private string $base_url = 'https://api.kinsta.com/v2';
public function __construct(string $api_key, string $company_id) {
$this->api_key = $api_key;
$this->company_id = $company_id;
}
private function request(string $method, string $endpoint, array $data = []): array {
$ch = curl_init($this->base_url . $endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->api_key,
'Content-Type: application/json',
],
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode($response, true);
if ($http_code >= 400) {
throw new RuntimeException(
"API error ($http_code): " . ($decoded['message'] ?? 'Unknown error')
);
}
return $decoded;
}
public function createSite(array $config): string {
$payload = array_merge([
'company' => $this->company_id,
'install_mode' => 'new',
'is_subdomain_multisite' => false,
'is_multisite' => false,
'wp_language' => 'en_US',
], $config);
$response = $this->request('POST', '/sites', $payload);
return $response['operation_id'];
}
public function waitForOperation(string $operation_id, int $timeout = 600): bool {
$start = time();
while (time() - $start < $timeout) {
$response = $this->request('GET', "/operations/$operation_id");
switch ($response['status']) {
case 'has_completed':
return true;
case 'has_failed':
throw new RuntimeException("Operation $operation_id failed.");
case 'is_running':
sleep(10);
break;
}
}
throw new RuntimeException("Operation timed out after {$timeout}s.");
}
public function provisionClientSite(
string $client_name,
string $admin_email,
string $region = 'us-central1'
): array {
$display_name = 'Client ' . $client_name;
$admin_user = sanitize_title($client_name) . '-admin';
$admin_password = bin2hex(random_bytes(16));
echo "Creating site: $display_name\n";
$operation_id = $this->createSite([
'display_name' => $display_name,
'region' => $region,
'admin_email' => $admin_email,
'admin_password' => $admin_password,
'admin_user' => $admin_user,
'site_title' => $client_name,
'woocommerce' => false,
'wordpressseo' => false,
]);
echo "Waiting for provisioning (operation: $operation_id)...\n";
$this->waitForOperation($operation_id);
echo "Site provisioned successfully.\n";
return [
'display_name' => $display_name,
'admin_user' => $admin_user,
'admin_password' => $admin_password,
'admin_email' => $admin_email,
'region' => $region,
];
}
}
// Usage
$provisioner = new KinstaSiteProvisioner(
getenv('KINSTA_API_KEY'),
getenv('KINSTA_COMPANY_ID')
);
$result = $provisioner->provisionClientSite(
'Acme Corporation',
'[email protected]',
'us-central1'
);
print_r($result);
This pattern of wrapping the Kinsta API in a class with built-in operation polling is one you will reuse across every automation script. The request method handles authentication, JSON encoding, and basic error checking in one place.
Bulk Operations: PHP Version Upgrades and Cache Purging
When a new PHP version drops, you do not want to log into MyKinsta and click “Change PHP version” on 50 sites. You want a script that handles all of them in one pass, with proper error handling and reporting.
Listing All Site Environments
Before performing bulk operations, you need to enumerate your environments. Each site can have multiple environments (live, staging, etc.), and most operations target a specific environment, not the site itself.
# Get all sites
sites=$(curl -s "https://api.kinsta.com/v2/sites?company=$KINSTA_COMPANY_ID" \
-H "Authorization: Bearer $KINSTA_API_KEY")
# Extract site IDs
site_ids=$(echo "$sites" | jq -r '.company.sites[].id')
# For each site, get environments
for site_id in $site_ids; do
envs=$(curl -s "https://api.kinsta.com/v2/sites/$site_id/environments" \
-H "Authorization: Bearer $KINSTA_API_KEY")
echo "$envs" | jq -r '.site.environments[] | "\(.id) \(.name) \(.is_premium)"'
done
Bulk Cache Purging
Clearing the site cache across all environments is a common agency task. Maybe you just rolled out a global CSS change via a shared mu-plugin, or a CDN configuration changed. Here is a script that clears cache on every live environment:
#!/bin/bash
set -euo pipefail
API_KEY="$KINSTA_API_KEY"
COMPANY_ID="$KINSTA_COMPANY_ID"
BASE_URL="https://api.kinsta.com/v2"
echo "Fetching all sites..."
sites=$(curl -s "$BASE_URL/sites?company=$COMPANY_ID" \
-H "Authorization: Bearer $API_KEY")
site_ids=$(echo "$sites" | jq -r '.company.sites[].id')
declare -a operations=()
for site_id in $site_ids; do
envs=$(curl -s "$BASE_URL/sites/$site_id/environments" \
-H "Authorization: Bearer $API_KEY")
live_env_id=$(echo "$envs" | jq -r '.site.environments[] | select(.name == "live") | .id')
if [ -n "$live_env_id" ]; then
echo "Clearing cache for environment: $live_env_id"
response=$(curl -s "$BASE_URL/sites/tools/clear-cache" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"environment_id\": \"$live_env_id\"}")
op_id=$(echo "$response" | jq -r '.operation_id')
if [ "$op_id" != "null" ] && [ -n "$op_id" ]; then
operations+=("$op_id")
echo " Queued operation: $op_id"
else
echo " Warning: No operation ID returned for $live_env_id"
echo " Response: $response"
fi
fi
done
echo ""
echo "Waiting for all operations to complete..."
for op_id in "${operations[@]}"; do
attempt=0
while [ $attempt -lt 30 ]; do
status=$(curl -s "$BASE_URL/operations/$op_id" \
-H "Authorization: Bearer $API_KEY" | jq -r '.status')
if [ "$status" = "has_completed" ]; then
echo " $op_id: completed"
break
elif [ "$status" = "has_failed" ]; then
echo " $op_id: FAILED"
break
fi
sleep 5
attempt=$((attempt + 1))
done
done
echo "Bulk cache clear finished."
Bulk PHP Version Updates
Updating the PHP version for an environment uses the PUT method on the environment endpoint. Here is how to upgrade all live environments to PHP 8.2:
#!/bin/bash
set -euo pipefail
TARGET_PHP="8.2"
API_KEY="$KINSTA_API_KEY"
COMPANY_ID="$KINSTA_COMPANY_ID"
BASE_URL="https://api.kinsta.com/v2"
echo "Upgrading all live environments to PHP $TARGET_PHP"
sites=$(curl -s "$BASE_URL/sites?company=$COMPANY_ID" \
-H "Authorization: Bearer $API_KEY")
site_ids=$(echo "$sites" | jq -r '.company.sites[].id')
for site_id in $site_ids; do
site_name=$(echo "$sites" | jq -r --arg id "$site_id" '.company.sites[] | select(.id == $id) | .display_name')
envs=$(curl -s "$BASE_URL/sites/$site_id/environments" \
-H "Authorization: Bearer $API_KEY")
live_env_id=$(echo "$envs" | jq -r '.site.environments[] | select(.name == "live") | .id')
if [ -n "$live_env_id" ]; then
current_php=$(echo "$envs" | jq -r '.site.environments[] | select(.name == "live") | .container_info.php_engine_version')
if [ "$current_php" = "$TARGET_PHP" ]; then
echo "SKIP: $site_name already on PHP $TARGET_PHP"
continue
fi
echo "UPDATING: $site_name from PHP $current_php to $TARGET_PHP"
response=$(curl -s "$BASE_URL/sites/tools/modify-php-version" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X PUT \
-d "{\"environment_id\": \"$live_env_id\", \"php_version\": \"$TARGET_PHP\"}")
op_id=$(echo "$response" | jq -r '.operation_id')
echo " Operation: $op_id"
# Wait for this one before proceeding to avoid overwhelming the API
sleep 2
fi
done
One important note: PHP version changes trigger a container restart. The site experiences a brief interruption (usually under 10 seconds). For production sites, schedule these updates during low-traffic windows. The script above runs sequentially with a delay between each site for exactly this reason.
Staging Environment Management
Kinsta’s staging environments are one of the platform’s best features, and the API gives you full programmatic control over creating, pushing to, and pulling from staging.
Creating a Staging Environment
Every Kinsta site can have multiple environments. The Standard Staging Environment is free; Premium Staging Environments are a paid add-on with production-level resources. Here is how to create a standard staging environment:
curl -s "https://api.kinsta.com/v2/sites/environments" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"site_id": "YOUR_SITE_ID",
"display_name": "staging",
"is_premium": false,
"source_env_id": "LIVE_ENVIRONMENT_ID"
}'
The source_env_id tells Kinsta which environment to clone. Pass the live environment ID to create a staging copy of your production site. This copies the database, files, and configuration.
Push Staging to Live
After testing changes in staging, you can push them to the live environment through the API. This is the equivalent of clicking “Push to Live” in MyKinsta:
curl -s "https://api.kinsta.com/v2/sites/environments/STAGING_ENV_ID/push-to-live" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"source_env_id": "STAGING_ENV_ID",
"target_env_id": "LIVE_ENV_ID",
"push_db": true,
"push_files": true
}'
You can selectively push only the database or only files by toggling those boolean flags. A common pattern is to push only files when deploying theme or plugin updates that do not require database changes.
Automated Staging Refresh Script
Agencies frequently need to refresh staging environments with the latest production data. Maybe the client added new content, or the production database has drifted from staging. Here is a script that automates the refresh cycle:
<?php
class KinstaStagingManager {
private string $api_key;
private string $base_url = 'https://api.kinsta.com/v2';
public function __construct(string $api_key) {
$this->api_key = $api_key;
}
private function request(string $method, string $endpoint, array $data = []): array {
$url = $this->base_url . $endpoint;
$ch = curl_init($url);
$headers = [
'Authorization: Bearer ' . $this->api_key,
'Content-Type: application/json',
];
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
];
if ($method === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = json_encode($data);
} elseif ($method === 'PUT') {
$opts[CURLOPT_CUSTOMREQUEST] = 'PUT';
$opts[CURLOPT_POSTFIELDS] = json_encode($data);
} elseif ($method === 'DELETE') {
$opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
}
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'code' => $http_code,
'body' => json_decode($response, true),
];
}
private function waitForOperation(string $op_id, int $timeout = 900): bool {
$start = time();
while (time() - $start < $timeout) {
$result = $this->request('GET', "/operations/$op_id");
$status = $result['body']['status'] ?? 'unknown';
if ($status === 'has_completed') return true;
if ($status === 'has_failed') return false;
sleep(15);
}
return false;
}
public function refreshStaging(string $site_id): bool {
// Step 1: Get environments
$result = $this->request('GET', "/sites/$site_id/environments");
$environments = $result['body']['site']['environments'] ?? [];
$live_env = null;
$staging_env = null;
foreach ($environments as $env) {
if ($env['name'] === 'live') $live_env = $env;
if (str_contains($env['name'], 'staging') || $env['is_premium'] === false && $env['name'] !== 'live') {
$staging_env = $env;
}
}
if (!$live_env) {
echo "ERROR: No live environment found.\n";
return false;
}
// Step 2: Delete existing staging if present
if ($staging_env) {
echo "Deleting existing staging environment...\n";
$delete_result = $this->request('DELETE', "/sites/environments/{$staging_env['id']}");
if (isset($delete_result['body']['operation_id'])) {
$this->waitForOperation($delete_result['body']['operation_id']);
}
sleep(10); // Brief pause after deletion
}
// Step 3: Create fresh staging from live
echo "Creating fresh staging from live...\n";
$create_result = $this->request('POST', '/sites/environments', [
'site_id' => $site_id,
'display_name' => 'staging',
'is_premium' => false,
'source_env_id' => $live_env['id'],
]);
if (!isset($create_result['body']['operation_id'])) {
echo "ERROR: Failed to create staging environment.\n";
echo json_encode($create_result['body'], JSON_PRETTY_PRINT) . "\n";
return false;
}
$success = $this->waitForOperation($create_result['body']['operation_id']);
if ($success) {
echo "Staging environment refreshed successfully.\n";
} else {
echo "ERROR: Staging creation operation failed.\n";
}
return $success;
}
}
$manager = new KinstaStagingManager(getenv('KINSTA_API_KEY'));
$manager->refreshStaging('your-site-id-here');
This “delete and recreate” approach gives you a perfectly clean staging environment every time. It is more reliable than trying to selectively sync data from production to an existing staging environment.
DNS and Domain Management
Kinsta’s API provides endpoints for managing DNS zones and records if you use Kinsta DNS. This is particularly useful for agencies managing domain configuration across many client sites.
Listing DNS Zones
First, retrieve all DNS zones associated with your company:
curl -s "https://api.kinsta.com/v2/dns/zones" \
-H "Authorization: Bearer $KINSTA_API_KEY" | jq '.'
Adding DNS Records
You can add A, AAAA, CNAME, MX, TXT, SRV, and other record types programmatically:
# Add an A record
curl -s "https://api.kinsta.com/v2/dns/zones/ZONE_ID/dns-records" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"type": "A",
"name": "@",
"value": "192.0.2.1",
"ttl": 3600
}'
# Add a CNAME record for www
curl -s "https://api.kinsta.com/v2/dns/zones/ZONE_ID/dns-records" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"type": "CNAME",
"name": "www",
"value": "example.com",
"ttl": 3600
}'
# Add a TXT record for SPF
curl -s "https://api.kinsta.com/v2/dns/zones/ZONE_ID/dns-records" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"type": "TXT",
"name": "@",
"value": "v=spf1 include:_spf.google.com ~all",
"ttl": 3600
}'
Automated Domain Setup for New Client Sites
When onboarding a new client, you often need to configure the domain, add DNS records for email verification, and set up the www redirect. Here is a function that handles the full domain setup:
setup_client_domain() {
local site_id="$1"
local domain="$2"
local site_ip="$3"
echo "Setting up domain: $domain for site: $site_id"
# Get the site's environment ID
live_env_id=$(curl -s "https://api.kinsta.com/v2/sites/$site_id/environments" \
-H "Authorization: Bearer $KINSTA_API_KEY" | \
jq -r '.site.environments[] | select(.name == "live") | .id')
# Add the custom domain to the environment
curl -s "https://api.kinsta.com/v2/sites/environments/$live_env_id/domains" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"domain\": \"$domain\"}"
# Add www subdomain
curl -s "https://api.kinsta.com/v2/sites/environments/$live_env_id/domains" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"domain\": \"www.$domain\"}"
echo "Domain $domain and www.$domain added to environment $live_env_id"
}
# Usage
setup_client_domain "site-id-here" "acmecorp.com" "35.xxx.xxx.xxx"
Building a Custom Agency Dashboard
The real power of the Kinsta API shows up when you build internal tooling. A custom dashboard can show your entire portfolio at a glance, surface sites that need attention, and let your team trigger operations without logging into MyKinsta.
Data Aggregation Script
Start by building a script that pulls all relevant data into a single JSON structure. This becomes the data source for your dashboard:
<?php
class KinstaAgencyDashboard {
private string $api_key;
private string $company_id;
private string $base_url = 'https://api.kinsta.com/v2';
public function __construct(string $api_key, string $company_id) {
$this->api_key = $api_key;
$this->company_id = $company_id;
}
private function get(string $endpoint): array {
$ch = curl_init($this->base_url . $endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->api_key,
'Content-Type: application/json',
],
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true) ?? [];
}
public function getSitePortfolio(): array {
$sites_response = $this->get("/sites?company={$this->company_id}");
$sites = $sites_response['company']['sites'] ?? [];
$portfolio = [];
foreach ($sites as $site) {
$env_response = $this->get("/sites/{$site['id']}/environments");
$environments = $env_response['site']['environments'] ?? [];
$live_env = null;
foreach ($environments as $env) {
if ($env['name'] === 'live') {
$live_env = $env;
break;
}
}
$site_data = [
'id' => $site['id'],
'name' => $site['display_name'],
'status' => $site['status'],
'environments' => count($environments),
];
if ($live_env) {
$site_data['php_version'] = $live_env['container_info']['php_engine_version'] ?? 'unknown';
$site_data['wp_version'] = $live_env['container_info']['wp_version'] ?? 'unknown';
$site_data['datacenter'] = $live_env['container_info']['datacenter'] ?? 'unknown';
// Get domains
$domains_response = $this->get("/sites/environments/{$live_env['id']}/domains");
$site_data['domains'] = array_map(
fn($d) => $d['name'],
$domains_response['environment']['domains'] ?? []
);
}
$portfolio[] = $site_data;
}
return $portfolio;
}
public function generateReport(): void {
$portfolio = $this->getSitePortfolio();
echo "=== Agency Portfolio Report ===\n";
echo "Total Sites: " . count($portfolio) . "\n\n";
// PHP version summary
$php_versions = [];
foreach ($portfolio as $site) {
$v = $site['php_version'] ?? 'unknown';
$php_versions[$v] = ($php_versions[$v] ?? 0) + 1;
}
echo "PHP Version Distribution:\n";
foreach ($php_versions as $version => $count) {
echo " PHP $version: $count sites\n";
}
echo "\nSites requiring attention:\n";
foreach ($portfolio as $site) {
$issues = [];
if (version_compare($site['php_version'] ?? '0', '8.1', '<')) {
$issues[] = "outdated PHP ({$site['php_version']})";
}
if (!empty($issues)) {
echo " {$site['name']}: " . implode(', ', $issues) . "\n";
}
}
// Output full data as JSON for dashboard consumption
file_put_contents(
'portfolio-report.json',
json_encode($portfolio, JSON_PRETTY_PRINT)
);
echo "\nFull report written to portfolio-report.json\n";
}
}
$dashboard = new KinstaAgencyDashboard(
getenv('KINSTA_API_KEY'),
getenv('KINSTA_COMPANY_ID')
);
$dashboard->generateReport();
JavaScript Dashboard Frontend
For a web-based dashboard, you can build a simple frontend that consumes the API through a server-side proxy (never expose your API key to the browser). Here is a Node.js/Express endpoint example:
const express = require('express');
const fetch = require('node-fetch');
const app = express();
const KINSTA_API_KEY = process.env.KINSTA_API_KEY;
const KINSTA_COMPANY_ID = process.env.KINSTA_COMPANY_ID;
const BASE_URL = 'https://api.kinsta.com/v2';
async function kinstaGet(endpoint) {
const response = await fetch(`${BASE_URL}${endpoint}`, {
headers: {
'Authorization': `Bearer ${KINSTA_API_KEY}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Kinsta API error: ${response.status}`);
}
return response.json();
}
app.get('/api/portfolio', async (req, res) => {
try {
const sitesData = await kinstaGet(`/sites?company=${KINSTA_COMPANY_ID}`);
const sites = sitesData.company.sites;
const portfolio = await Promise.all(sites.map(async (site) => {
const envData = await kinstaGet(`/sites/${site.id}/environments`);
const liveEnv = envData.site.environments.find(e => e.name === 'live');
return {
id: site.id,
name: site.display_name,
status: site.status,
phpVersion: liveEnv?.container_info?.php_engine_version || 'unknown',
wpVersion: liveEnv?.container_info?.wp_version || 'unknown',
datacenter: liveEnv?.container_info?.datacenter || 'unknown',
envId: liveEnv?.id,
};
}));
res.json({ sites: portfolio, total: portfolio.length });
} catch (error) {
console.error('Portfolio fetch error:', error);
res.status(500).json({ error: 'Failed to fetch portfolio' });
}
});
app.post('/api/clear-cache/:envId', async (req, res) => {
try {
const response = await fetch(`${BASE_URL}/sites/tools/clear-cache`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KINSTA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ environment_id: req.params.envId }),
});
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).json({ error: 'Cache clear failed' });
}
});
app.listen(3000, () => console.log('Dashboard running on port 3000'));
This gives your team a centralized place to see every client site’s status, PHP version, WordPress version, and datacenter location. You can extend it with buttons to clear cache, trigger backups, or refresh staging environments, all without touching MyKinsta.
Backup Automation and Scheduled Operations
Kinsta automatically creates daily backups, but the API lets you trigger manual backups on demand. This is critical before deployments, migrations, or any operation where you want a known-good restore point.
Triggering a Manual Backup
curl -s "https://api.kinsta.com/v2/sites/environments/ENV_ID/manual-backups" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"tag": "pre-deployment-backup"
}'
The tag field is a label for the backup. Use descriptive tags so you can identify backups later: pre-deployment, pre-migration, weekly-manual, etc.
Listing Available Backups
curl -s "https://api.kinsta.com/v2/sites/environments/ENV_ID/backups" \
-H "Authorization: Bearer $KINSTA_API_KEY" | jq '.environment.backups[] | {id: .id, name: .name, type: .type, created_at: .created_at}'
Restoring from a Backup
curl -s "https://api.kinsta.com/v2/sites/environments/ENV_ID/backups/BACKUP_ID/restore" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST
Pre-Deployment Backup Script
Here is a bash script that takes a backup before every deployment and only proceeds if the backup succeeds:
#!/bin/bash
set -euo pipefail
ENV_ID="$1"
DEPLOY_TAG="${2:-deployment}"
echo "Taking pre-deployment backup..."
backup_response=$(curl -s "https://api.kinsta.com/v2/sites/environments/$ENV_ID/manual-backups" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"tag\": \"pre-$DEPLOY_TAG-$(date +%Y%m%d-%H%M%S)\"}")
op_id=$(echo "$backup_response" | jq -r '.operation_id')
if [ "$op_id" = "null" ] || [ -z "$op_id" ]; then
echo "ERROR: Failed to trigger backup."
echo "$backup_response" | jq '.'
exit 1
fi
echo "Backup operation: $op_id"
attempt=0
while [ $attempt -lt 60 ]; do
status=$(curl -s "https://api.kinsta.com/v2/operations/$op_id" \
-H "Authorization: Bearer $KINSTA_API_KEY" | jq -r '.status')
case "$status" in
"has_completed")
echo "Backup completed successfully. Safe to deploy."
exit 0
;;
"has_failed")
echo "ERROR: Backup failed. Aborting deployment."
exit 1
;;
"is_running")
echo " Backup in progress... ($attempt)"
sleep 10
;;
esac
attempt=$((attempt + 1))
done
echo "ERROR: Backup timed out. Aborting deployment."
exit 1
Run this as part of your deployment pipeline: ./pre-deploy-backup.sh your-env-id release-v2.3.1. If the backup fails, the script exits with a non-zero code and your deployment stops.
Integrating Kinsta API with GitHub Actions
This is where everything comes together for agencies with code-driven workflows. GitHub Actions can trigger Kinsta operations as part of your deployment pipeline: take a backup, clear the cache, refresh staging, or even provision new sites.
GitHub Actions Secrets Setup
Store your Kinsta credentials as repository secrets:
KINSTA_API_KEY: Your API keyKINSTA_COMPANY_ID: Your company IDKINSTA_LIVE_ENV_ID: The environment ID for your production siteKINSTA_STAGING_ENV_ID: The environment ID for staging
Cache Clear on Deployment
The simplest integration: clear the Kinsta cache after every successful deployment.
name: Deploy and Clear Cache
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to Kinsta
run: |
# Your deployment logic here (rsync, SSH, Git push, etc.)
echo "Deploying..."
- name: Clear Kinsta Cache
run: |
response=$(curl -s -w "\n%{http_code}" \
"https://api.kinsta.com/v2/sites/tools/clear-cache" \
-H "Authorization: Bearer ${{ secrets.KINSTA_API_KEY }}" \
-H "Content-Type: application/json" \
-X POST \
-d '{"environment_id": "${{ secrets.KINSTA_LIVE_ENV_ID }}"}')
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -1)
if [ "$http_code" -ge 400 ]; then
echo "Cache clear failed with HTTP $http_code"
echo "$body"
exit 1
fi
echo "Cache clear triggered successfully"
echo "$body" | jq '.'
Full Deployment Pipeline with Backup, Deploy, and Cache Clear
Here is a production-grade GitHub Actions workflow that backs up the site before deploying, deploys the code, clears the cache, and verifies the site is responding:
name: Production Deploy Pipeline
on:
push:
branches: [main]
workflow_dispatch:
env:
KINSTA_API_KEY: ${{ secrets.KINSTA_API_KEY }}
KINSTA_ENV_ID: ${{ secrets.KINSTA_LIVE_ENV_ID }}
jobs:
pre-deploy-backup:
runs-on: ubuntu-latest
steps:
- name: Trigger backup
id: backup
run: |
response=$(curl -s "https://api.kinsta.com/v2/sites/environments/$KINSTA_ENV_ID/manual-backups" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{"tag": "pre-deploy-${{ github.sha }}"}')
op_id=$(echo "$response" | jq -r '.operation_id')
echo "operation_id=$op_id" >> $GITHUB_OUTPUT
- name: Wait for backup
run: |
op_id="${{ steps.backup.outputs.operation_id }}"
for i in $(seq 1 60); do
status=$(curl -s "https://api.kinsta.com/v2/operations/$op_id" \
-H "Authorization: Bearer $KINSTA_API_KEY" | jq -r '.status')
if [ "$status" = "has_completed" ]; then
echo "Backup complete."
exit 0
elif [ "$status" = "has_failed" ]; then
echo "Backup FAILED. Aborting pipeline."
exit 1
fi
echo "Backup in progress... ($i/60)"
sleep 10
done
echo "Backup timed out."
exit 1
deploy:
needs: pre-deploy-backup
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /www/your-site/public
git pull origin main
wp cache flush
post-deploy:
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Clear Kinsta edge cache
run: |
curl -s "https://api.kinsta.com/v2/sites/tools/clear-cache" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"environment_id\": \"$KINSTA_ENV_ID\"}"
- name: Clear CDN cache
run: |
curl -s "https://api.kinsta.com/v2/sites/tools/clear-cdn-cache" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"environment_id\": \"$KINSTA_ENV_ID\"}"
- name: Verify site is responding
run: |
sleep 15
status_code=$(curl -s -o /dev/null -w "%{http_code}" "${{ secrets.SITE_URL }}")
if [ "$status_code" != "200" ]; then
echo "WARNING: Site returned HTTP $status_code after deploy"
exit 1
fi
echo "Site is responding with HTTP 200."
This pipeline has three stages. The backup must succeed before deployment starts. If the backup fails, the entire pipeline stops. After deployment, the cache clear and site verification run automatically.
Scheduled Staging Refresh with GitHub Actions
You can use a cron-triggered workflow to automatically refresh staging environments every week:
name: Weekly Staging Refresh
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 6 AM UTC
workflow_dispatch:
jobs:
refresh-staging:
runs-on: ubuntu-latest
steps:
- name: Delete existing staging
id: delete
run: |
response=$(curl -s \
"https://api.kinsta.com/v2/sites/environments/${{ secrets.KINSTA_STAGING_ENV_ID }}" \
-H "Authorization: Bearer ${{ secrets.KINSTA_API_KEY }}" \
-X DELETE)
op_id=$(echo "$response" | jq -r '.operation_id // empty')
if [ -n "$op_id" ]; then
echo "Deleting staging, operation: $op_id"
for i in $(seq 1 60); do
status=$(curl -s "https://api.kinsta.com/v2/operations/$op_id" \
-H "Authorization: Bearer ${{ secrets.KINSTA_API_KEY }}" | jq -r '.status')
[ "$status" = "has_completed" ] && break
[ "$status" = "has_failed" ] && exit 1
sleep 10
done
fi
- name: Create fresh staging from live
run: |
response=$(curl -s "https://api.kinsta.com/v2/sites/environments" \
-H "Authorization: Bearer ${{ secrets.KINSTA_API_KEY }}" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"site_id": "${{ secrets.KINSTA_SITE_ID }}",
"display_name": "staging",
"is_premium": false,
"source_env_id": "${{ secrets.KINSTA_LIVE_ENV_ID }}"
}')
op_id=$(echo "$response" | jq -r '.operation_id')
echo "Creating staging, operation: $op_id"
for i in $(seq 1 90); do
status=$(curl -s "https://api.kinsta.com/v2/operations/$op_id" \
-H "Authorization: Bearer ${{ secrets.KINSTA_API_KEY }}" | jq -r '.status')
if [ "$status" = "has_completed" ]; then
echo "Staging refresh complete."
exit 0
elif [ "$status" = "has_failed" ]; then
echo "Staging creation FAILED."
exit 1
fi
sleep 15
done
- name: Notify team
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then
echo "Staging refreshed successfully at $(date)"
else
echo "Staging refresh failed at $(date)"
fi
Every Monday morning, your staging environment gets rebuilt from the latest production data. Your QA team always starts the week with a fresh copy.
Error Handling, Rate Limiting, and Monitoring
Production automation code must handle failures gracefully. The Kinsta API has rate limits, returns various error codes, and can experience temporary issues. Your code needs to account for all of this.
Rate Limiting
Kinsta enforces rate limits on API requests. The current limits are documented in their API reference, but as a general rule, keep your requests under 60 per minute per API key. If you exceed the limit, the API returns a 429 Too Many Requests response.
Here is a PHP function with built-in retry logic and exponential backoff:
<?php
function kinsta_request_with_retry(
string $method,
string $endpoint,
array $data = [],
int $max_retries = 3
): array {
$base_url = 'https://api.kinsta.com/v2';
$api_key = getenv('KINSTA_API_KEY');
$retry_delay = 2;
for ($attempt = 0; $attempt <= $max_retries; $attempt++) {
$ch = curl_init($base_url . $endpoint);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $api_key,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
];
if ($method === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = json_encode($data);
} elseif ($method !== 'GET') {
$opts[CURLOPT_CUSTOMREQUEST] = $method;
if (!empty($data)) {
$opts[CURLOPT_POSTFIELDS] = json_encode($data);
}
}
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
// Handle curl errors (network issues, timeouts)
if ($curl_error) {
error_log("Kinsta API curl error (attempt $attempt): $curl_error");
if ($attempt < $max_retries) {
sleep($retry_delay);
$retry_delay *= 2;
continue;
}
throw new RuntimeException("Kinsta API network error: $curl_error");
}
$decoded = json_decode($response, true) ?? [];
// Handle rate limiting
if ($http_code === 429) {
$wait = $retry_delay * ($attempt + 1);
error_log("Kinsta API rate limited. Waiting {$wait}s before retry.");
sleep($wait);
$retry_delay *= 2;
continue;
}
// Handle server errors (retry)
if ($http_code >= 500) {
error_log("Kinsta API server error ($http_code) on attempt $attempt");
if ($attempt < $max_retries) {
sleep($retry_delay);
$retry_delay *= 2;
continue;
}
}
// Handle client errors (do not retry)
if ($http_code >= 400 && $http_code < 500 && $http_code !== 429) {
throw new RuntimeException(
"Kinsta API client error ($http_code): " . ($decoded['message'] ?? 'Unknown')
);
}
return [
'code' => $http_code,
'body' => $decoded,
];
}
throw new RuntimeException("Kinsta API request failed after $max_retries retries.");
}
The key principles here: retry on 429 (rate limit) and 5xx (server errors), but do not retry on 4xx client errors (those are your fault). Use exponential backoff so you do not hammer the API when it is already struggling.
Webhook-Based Monitoring
Instead of polling for operation status, you can build a webhook receiver that Kinsta notifies when operations complete. While the Kinsta API does not currently offer native webhook subscriptions for all events, you can build a monitoring layer that checks site status and sends alerts.
Here is a simple monitoring script that checks all your sites and sends a Slack notification if anything is down:
#!/bin/bash
set -euo pipefail
SLACK_WEBHOOK="$SLACK_WEBHOOK_URL"
API_KEY="$KINSTA_API_KEY"
COMPANY_ID="$KINSTA_COMPANY_ID"
BASE_URL="https://api.kinsta.com/v2"
sites=$(curl -s "$BASE_URL/sites?company=$COMPANY_ID" \
-H "Authorization: Bearer $API_KEY")
site_count=$(echo "$sites" | jq '.company.sites | length')
issues=()
for i in $(seq 0 $((site_count - 1))); do
site_name=$(echo "$sites" | jq -r ".company.sites[$i].display_name")
site_id=$(echo "$sites" | jq -r ".company.sites[$i].id")
site_status=$(echo "$sites" | jq -r ".company.sites[$i].status")
if [ "$site_status" != "live" ]; then
issues+=("$site_name: status is $site_status")
fi
done
if [ ${#issues[@]} -gt 0 ]; then
message="*Kinsta Site Alert*\n"
for issue in "${issues[@]}"; do
message+="- $issue\n"
done
curl -s -X POST "$SLACK_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{\"text\": \"$message\"}"
fi
Run this script on a cron schedule (every 5 minutes) via GitHub Actions, a dedicated monitoring server, or a Lambda function. It gives you instant visibility into site issues without waiting for a client to report a problem.
Structured Error Logging
For production automation, you need structured logs. Here is a PHP logging pattern that captures API interactions in a format you can feed into any log aggregation tool:
<?php
class KinstaApiLogger {
private string $log_file;
public function __construct(string $log_file = '/var/log/kinsta-api.log') {
$this->log_file = $log_file;
}
public function log(string $level, string $message, array $context = []): void {
$entry = [
'timestamp' => date('c'),
'level' => $level,
'message' => $message,
'context' => $context,
];
file_put_contents(
$this->log_file,
json_encode($entry) . "\n",
FILE_APPEND | LOCK_EX
);
}
public function logApiCall(
string $method,
string $endpoint,
int $http_code,
float $duration,
?string $error = null
): void {
$this->log($error ? 'error' : 'info', 'Kinsta API call', [
'method' => $method,
'endpoint' => $endpoint,
'http_code' => $http_code,
'duration' => round($duration, 3),
'error' => $error,
]);
}
}
// Usage in your API wrapper
$logger = new KinstaApiLogger();
$start = microtime(true);
// ... make API call ...
$duration = microtime(true) - $start;
$logger->logApiCall('GET', '/sites', 200, $duration);
When something goes wrong at 2 AM, these logs tell you exactly what API call failed, what the response was, and how long it took. That information is the difference between a 5-minute fix and an hour of guessing.
Real-World Agency Automation Scripts
Let us tie everything together with complete, production-ready scripts that solve actual agency problems.
Script 1: Client Onboarding Automation
This script handles the full onboarding flow: create the site, configure the domain, take an initial backup, and generate a report for the client.
#!/bin/bash
set -euo pipefail
# Configuration
CLIENT_NAME="$1"
CLIENT_DOMAIN="$2"
CLIENT_EMAIL="$3"
REGION="${4:-us-central1}"
API_KEY="$KINSTA_API_KEY"
COMPANY_ID="$KINSTA_COMPANY_ID"
BASE_URL="https://api.kinsta.com/v2"
ADMIN_USER=$(echo "$CLIENT_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')"-admin"
ADMIN_PASS=$(openssl rand -base64 24)
echo "======================================="
echo " Client Onboarding: $CLIENT_NAME"
echo " Domain: $CLIENT_DOMAIN"
echo " Region: $REGION"
echo "======================================="
# Step 1: Create the site
echo ""
echo "[1/4] Creating site..."
create_response=$(curl -s "$BASE_URL/sites" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"company": "'"$COMPANY_ID"'",
"display_name": "'"$CLIENT_NAME"'",
"region": "'"$REGION"'",
"install_mode": "new",
"is_subdomain_multisite": false,
"admin_email": "'"$CLIENT_EMAIL"'",
"admin_password": "'"$ADMIN_PASS"'",
"admin_user": "'"$ADMIN_USER"'",
"is_multisite": false,
"site_title": "'"$CLIENT_NAME"'",
"woocommerce": false,
"wordpressseo": true,
"wp_language": "en_US"
}')
op_id=$(echo "$create_response" | jq -r '.operation_id')
echo " Operation: $op_id"
# Wait for site creation
attempt=0
while [ $attempt -lt 60 ]; do
status=$(curl -s "$BASE_URL/operations/$op_id" \
-H "Authorization: Bearer $API_KEY" | jq -r '.status')
if [ "$status" = "has_completed" ]; then
echo " Site created successfully."
break
elif [ "$status" = "has_failed" ]; then
echo " ERROR: Site creation failed."
exit 1
fi
sleep 10
attempt=$((attempt + 1))
done
# Step 2: Find the new site and environment
echo ""
echo "[2/4] Retrieving site details..."
sleep 5 # Brief pause for API consistency
sites=$(curl -s "$BASE_URL/sites?company=$COMPANY_ID" \
-H "Authorization: Bearer $API_KEY")
site_id=$(echo "$sites" | jq -r --arg name "$CLIENT_NAME" \
'.company.sites[] | select(.display_name == $name) | .id')
envs=$(curl -s "$BASE_URL/sites/$site_id/environments" \
-H "Authorization: Bearer $API_KEY")
live_env_id=$(echo "$envs" | jq -r '.site.environments[] | select(.name == "live") | .id')
echo " Site ID: $site_id"
echo " Environment ID: $live_env_id"
# Step 3: Add custom domain
echo ""
echo "[3/4] Adding domain $CLIENT_DOMAIN..."
curl -s "$BASE_URL/sites/environments/$live_env_id/domains" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"domain\": \"$CLIENT_DOMAIN\"}" > /dev/null
curl -s "$BASE_URL/sites/environments/$live_env_id/domains" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"domain\": \"www.$CLIENT_DOMAIN\"}" > /dev/null
echo " Domains added."
# Step 4: Take initial backup
echo ""
echo "[4/4] Creating initial backup..."
curl -s "$BASE_URL/sites/environments/$live_env_id/manual-backups" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{"tag": "initial-setup"}' > /dev/null
echo " Backup triggered."
# Generate report
echo ""
echo "======================================="
echo " ONBOARDING COMPLETE"
echo "======================================="
echo " Client: $CLIENT_NAME"
echo " Domain: $CLIENT_DOMAIN"
echo " Admin User: $ADMIN_USER"
echo " Admin Pass: $ADMIN_PASS"
echo " Admin Email: $CLIENT_EMAIL"
echo " Site ID: $site_id"
echo " Env ID: $live_env_id"
echo " Region: $REGION"
echo "======================================="
echo ""
echo "IMPORTANT: Share credentials securely."
echo "Do NOT send passwords via email."
Usage: ./onboard-client.sh "Acme Corp" "acmecorp.com" "[email protected]" "us-central1"
In under 5 minutes, you have a fully provisioned WordPress site with a custom domain, initial backup, and documented credentials. Compare that to the 15-20 minutes of manual clicking through MyKinsta.
Script 2: Monthly Maintenance Report Generator
Agencies often need to provide clients with monthly reports showing what maintenance was performed. This script generates a report by querying site details and environment information:
<?php
class KinstaMonthlyReport {
private string $api_key;
private string $company_id;
private string $base_url = 'https://api.kinsta.com/v2';
public function __construct(string $api_key, string $company_id) {
$this->api_key = $api_key;
$this->company_id = $company_id;
}
private function get(string $endpoint): array {
$ch = curl_init($this->base_url . $endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->api_key,
'Content-Type: application/json',
],
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true) ?? [];
}
public function generateReport(string $month_label = ''): string {
if (!$month_label) {
$month_label = date('F Y');
}
$sites_response = $this->get("/sites?company={$this->company_id}");
$sites = $sites_response['company']['sites'] ?? [];
$report = "# Monthly Maintenance Report: $month_label\n\n";
$report .= "Generated: " . date('Y-m-d H:i:s') . "\n";
$report .= "Total Sites: " . count($sites) . "\n\n";
$report .= "## Site Status Summary\n\n";
$report .= "| Site | PHP | WP Version | Datacenter | Status |\n";
$report .= "|------|-----|------------|------------|--------|\n";
$php_outdated = [];
$all_healthy = true;
foreach ($sites as $site) {
$env_response = $this->get("/sites/{$site['id']}/environments");
$environments = $env_response['site']['environments'] ?? [];
$live_env = null;
foreach ($environments as $env) {
if ($env['name'] === 'live') {
$live_env = $env;
break;
}
}
$php_version = $live_env['container_info']['php_engine_version'] ?? 'N/A';
$wp_version = $live_env['container_info']['wp_version'] ?? 'N/A';
$datacenter = $live_env['container_info']['datacenter'] ?? 'N/A';
$status = $site['status'] ?? 'unknown';
$report .= "| {$site['display_name']} | $php_version | $wp_version | $datacenter | $status |\n";
if (version_compare($php_version, '8.1', '<') && $php_version !== 'N/A') {
$php_outdated[] = $site['display_name'] . " (PHP $php_version)";
}
if ($status !== 'live') {
$all_healthy = false;
}
}
$report .= "\n## Recommendations\n\n";
if (!empty($php_outdated)) {
$report .= "### PHP Upgrades Needed\n";
$report .= "The following sites are running PHP versions older than 8.1:\n";
foreach ($php_outdated as $site_info) {
$report .= "- $site_info\n";
}
$report .= "\n";
}
if ($all_healthy) {
$report .= "All sites are healthy and operational.\n";
}
return $report;
}
}
$reporter = new KinstaMonthlyReport(
getenv('KINSTA_API_KEY'),
getenv('KINSTA_COMPANY_ID')
);
$report = $reporter->generateReport();
echo $report;
// Also save to file
file_put_contents('monthly-report-' . date('Y-m') . '.md', $report);
Script 3: Emergency Site Restore
When a client site goes down and you need to restore from the most recent backup immediately, you do not want to be fumbling through a dashboard. This script gets the latest backup and restores it:
#!/bin/bash
set -euo pipefail
SITE_NAME="$1"
API_KEY="$KINSTA_API_KEY"
COMPANY_ID="$KINSTA_COMPANY_ID"
BASE_URL="https://api.kinsta.com/v2"
echo "EMERGENCY RESTORE: $SITE_NAME"
echo "=========================="
# Find the site
sites=$(curl -s "$BASE_URL/sites?company=$COMPANY_ID" \
-H "Authorization: Bearer $API_KEY")
site_id=$(echo "$sites" | jq -r --arg name "$SITE_NAME" \
'.company.sites[] | select(.display_name == $name) | .id')
if [ -z "$site_id" ] || [ "$site_id" = "null" ]; then
echo "ERROR: Site '$SITE_NAME' not found."
exit 1
fi
# Get live environment
envs=$(curl -s "$BASE_URL/sites/$site_id/environments" \
-H "Authorization: Bearer $API_KEY")
live_env_id=$(echo "$envs" | jq -r '.site.environments[] | select(.name == "live") | .id')
echo "Site ID: $site_id"
echo "Environment: $live_env_id"
# Get the most recent backup
backups=$(curl -s "$BASE_URL/sites/environments/$live_env_id/backups" \
-H "Authorization: Bearer $API_KEY")
latest_backup_id=$(echo "$backups" | jq -r '.environment.backups[0].id')
latest_backup_name=$(echo "$backups" | jq -r '.environment.backups[0].name')
latest_backup_time=$(echo "$backups" | jq -r '.environment.backups[0].created_at')
echo ""
echo "Latest backup: $latest_backup_name"
echo "Created at: $latest_backup_time"
echo ""
echo "Restoring from backup $latest_backup_id..."
# Trigger restore
restore_response=$(curl -s "$BASE_URL/sites/environments/$live_env_id/backups/$latest_backup_id/restore" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X POST)
op_id=$(echo "$restore_response" | jq -r '.operation_id')
echo "Restore operation: $op_id"
# Wait for restore
attempt=0
while [ $attempt -lt 120 ]; do
status=$(curl -s "$BASE_URL/operations/$op_id" \
-H "Authorization: Bearer $API_KEY" | jq -r '.status')
case "$status" in
"has_completed")
echo ""
echo "RESTORE COMPLETE."
echo "Site should be back online. Verify immediately."
exit 0
;;
"has_failed")
echo ""
echo "RESTORE FAILED. Manual intervention required."
exit 1
;;
"is_running")
printf "."
sleep 5
;;
esac
attempt=$((attempt + 1))
done
echo ""
echo "RESTORE TIMED OUT. Check MyKinsta for status."
exit 1
Usage: ./emergency-restore.sh "Acme Corp"
That is a one-command site restore. In a crisis, those saved minutes matter.
Script 4: Multi-Site Deployment Coordinator
Agencies that manage a shared codebase across multiple client sites (think a common theme or plugin) need to deploy to many sites at once. This script coordinates a rolling deployment with backups:
#!/bin/bash
set -euo pipefail
API_KEY="$KINSTA_API_KEY"
COMPANY_ID="$KINSTA_COMPANY_ID"
BASE_URL="https://api.kinsta.com/v2"
# List of site IDs to deploy to
SITE_IDS=("site-id-1" "site-id-2" "site-id-3")
DEPLOY_TAG="multi-deploy-$(date +%Y%m%d-%H%M%S)"
echo "Multi-Site Deployment: $DEPLOY_TAG"
echo "Sites to deploy: ${#SITE_IDS[@]}"
echo ""
for site_id in "${SITE_IDS[@]}"; do
# Get site name and environment
envs=$(curl -s "$BASE_URL/sites/$site_id/environments" \
-H "Authorization: Bearer $API_KEY")
live_env_id=$(echo "$envs" | jq -r '.site.environments[] | select(.name == "live") | .id')
echo "Processing site: $site_id (env: $live_env_id)"
# Take pre-deployment backup
echo " Taking backup..."
backup_response=$(curl -s "$BASE_URL/sites/environments/$live_env_id/manual-backups" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"tag\": \"$DEPLOY_TAG\"}")
backup_op=$(echo "$backup_response" | jq -r '.operation_id')
# Wait for backup
for i in $(seq 1 30); do
status=$(curl -s "$BASE_URL/operations/$backup_op" \
-H "Authorization: Bearer $API_KEY" | jq -r '.status')
[ "$status" = "has_completed" ] && break
[ "$status" = "has_failed" ] && { echo " BACKUP FAILED. Skipping site."; continue 2; }
sleep 5
done
echo " Backup complete."
# Clear cache after deployment
echo " Clearing cache..."
curl -s "$BASE_URL/sites/tools/clear-cache" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"environment_id\": \"$live_env_id\"}" > /dev/null
echo " Done."
echo ""
# Rate limit protection
sleep 2
done
echo "Multi-site deployment complete: $DEPLOY_TAG"
Advanced Patterns and Tips
Before wrapping up, here are several advanced patterns that will save you time and headaches as your Kinsta API automation matures.
Idempotent Operations
Design your scripts to be idempotent whenever possible. If a script fails halfway through and you re-run it, it should pick up where it left off rather than creating duplicate resources. Check if a site exists before creating it. Check if a domain is already added before adding it. This prevents orphaned resources and duplicate operations.
# Check if site already exists before creating
existing_site=$(curl -s "$BASE_URL/sites?company=$COMPANY_ID" \
-H "Authorization: Bearer $API_KEY" | \
jq -r --arg name "$SITE_NAME" '.company.sites[] | select(.display_name == $name) | .id')
if [ -n "$existing_site" ] && [ "$existing_site" != "null" ]; then
echo "Site already exists with ID: $existing_site. Skipping creation."
else
echo "Creating new site..."
# ... creation logic
fi
Parallel Operations with Rate Limit Awareness
When you need to perform operations on many sites, you can run some requests in parallel while respecting rate limits. Use xargs or GNU parallel with a concurrency limit:
# Clear cache on all sites, 3 at a time
echo "$env_ids" | xargs -P 3 -I {} curl -s \
"https://api.kinsta.com/v2/sites/tools/clear-cache" \
-H "Authorization: Bearer $KINSTA_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{"environment_id": "{}"}'
Three concurrent requests is a safe starting point. Increase cautiously and watch for 429 responses.
Environment Variable Templates
Create a .env.kinsta template for your team so everyone uses the same variable names:
# .env.kinsta - Kinsta API configuration
KINSTA_API_KEY=
KINSTA_COMPANY_ID=
KINSTA_LIVE_ENV_ID=
KINSTA_STAGING_ENV_ID=
KINSTA_SITE_ID=
KINSTA_WEBHOOK_SECRET=
Load it in your scripts: source .env.kinsta
API Response Caching
If your dashboard polls the Kinsta API frequently, cache responses locally to reduce API calls. A 60-second cache for site listings is perfectly reasonable since site data does not change that often:
<?php
function cached_kinsta_get(string $endpoint, int $ttl = 60): array {
$cache_key = 'kinsta_' . md5($endpoint);
$cache_file = sys_get_temp_dir() . '/' . $cache_key . '.json';
if (file_exists($cache_file) && (time() - filemtime($cache_file)) < $ttl) {
return json_decode(file_get_contents($cache_file), true);
}
$data = kinsta_request_with_retry('GET', $endpoint);
file_put_contents($cache_file, json_encode($data));
return $data;
}
Testing Against Staging First
Never run untested automation scripts against production environments. Always test against a staging environment first. The API does not differentiate between "oops I ran this by accident" and "intentional production operation." A script that clears the wrong cache or restores the wrong backup in production can cause real downtime.
Build a --dry-run flag into your scripts that shows what would happen without executing:
DRY_RUN="${DRY_RUN:-false}"
if [ "$DRY_RUN" = "true" ]; then
echo "[DRY RUN] Would clear cache for environment: $env_id"
else
curl -s "$BASE_URL/sites/tools/clear-cache" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"environment_id\": \"$env_id\"}"
fi
Usage: DRY_RUN=true ./bulk-cache-clear.sh
Putting It All Together: The Agency Automation Playbook
Here is how all of these pieces fit into a real agency workflow:
Client signs up: Your onboarding script provisions the site, configures the domain, installs your standard plugin stack, and takes the initial backup. Credentials are stored in your password manager via API. Total time: under 5 minutes, zero manual clicks.
Weekly maintenance: A GitHub Actions cron job runs every Monday. It refreshes all staging environments from production, checks PHP versions, and generates a status report. Your team reviews the report at standup and addresses any issues flagged.
Deployment day: A developer merges a PR to main. GitHub Actions triggers the pipeline: backup production, deploy the code, clear edge and CDN caches, verify the site responds with HTTP 200. If any step fails, the pipeline stops and alerts the team.
Emergency response: A client reports their site is down. Your on-call engineer runs the emergency restore script, which finds the latest backup and restores it in one command. The site is back in under 5 minutes, long before the client finishes typing their follow-up email.
Monthly reporting: At month's end, the report generator pulls data from every site and produces a formatted summary showing PHP versions, WordPress versions, datacenter locations, and any issues that need attention. Attach it to the client invoice.
The Kinsta API turns these previously manual, error-prone tasks into repeatable, auditable automation. Every script logs its actions. Every operation is tracked by operation ID. Every backup is tagged with context about why it was created.
For agencies managing more than a handful of sites, this kind of automation is not optional. It is the difference between scaling your operations and hiring another person just to click buttons in a dashboard. The API is your leverage. Use it.
Tom Bradley
DevOps engineer focused on WordPress deployment automation. Builds CI/CD pipelines and infrastructure-as-code solutions for WordPress agencies.