Back to Blog
Platform Guides

Kinsta API Mastery: Automating WordPress Operations for Agencies at Scale

Tom Bradley
45 min read

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 key
  • KINSTA_COMPANY_ID: Your company ID
  • KINSTA_LIVE_ENV_ID: The environment ID for your production site
  • KINSTA_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.

Share this article

Tom Bradley

DevOps engineer focused on WordPress deployment automation. Builds CI/CD pipelines and infrastructure-as-code solutions for WordPress agencies.