PlanPlain API Reference
PlanPlain accepts Terraform plan JSON and returns a plain-English summary, risk flags, reviewer checklist, and PR-ready markdown — in one API call.
Base URL: https://planplain.polsia.app
Quick Start
Three steps from terraform plan to a PR comment.
Generate a free API key with your email. No account signup required.
curl -X POST https://planplain.polsia.app/api/keys \
-H "Content-Type: application/json" \
-d '{"email": "you@yourcompany.com"}'
{
"success": true,
"api_key": "pp_live_a1b2c3d4e5f6..." // save this — shown once
}
Run these commands in your Terraform directory to get the plan JSON.
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
POST the JSON file to /api/analyze. Get back a structured analysis you can post as a PR comment.
curl -X POST https://planplain.polsia.app/api/analyze \
-H "Authorization: Bearer pp_live_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d @plan.json
The response includes pr_markdown — paste it directly into your PR comment or automate via GitHub Actions.
See requests, error rate, response times, and rate limit status in real time.
Authentication
All requests to /api/analyze require a Bearer token in the Authorization header.
Authorization: Bearer pp_live_<your_key>
| Property | Value |
|---|---|
| Format | pp_live_ prefix + 48 hex characters |
| Storage | SHA-256 hashed server-side — we never store your raw key |
| Scope | Full access to /api/analyze |
| Max per email | 5 API keys |
Rate Limits
Rate limits are per API key using a sliding window.
When rate limited, the API returns HTTP 429 with a Retry-After header (seconds to wait).
Rate limit headers
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 7
Retry-After: 42 # only present when 429
POST /api/analyze
Analyzes a Terraform plan JSON and returns a plain-English breakdown with risk assessment.
Accepts the output of terraform show -json tfplan. Returns structured analysis + PR markdown.
Request body
Pass the raw Terraform plan JSON as the request body. This is the complete JSON output from terraform show -json.
| Field | Type | Required | Description |
|---|---|---|---|
| plan_json | object | optional | Nested under a key. Or send the raw plan JSON as the body directly. |
| resource_changes | array | required | Array of resource change objects. This field must exist in the parsed JSON. |
terraform show -json is accepted directly.Response body
| Field | Type | Description |
|---|---|---|
| success | boolean | true on success |
| analysis.summary | string | Plain-English description of what this plan does |
| analysis.risk_flags | array | Array of { severity, resource, message } objects. Severity: HIGH, MED, LOW |
| analysis.reviewer_checklist | array | String array of things a reviewer should verify |
| analysis.pr_markdown | string | Formatted markdown ready to post as a PR comment |
| metadata | object | Resource counts, max risk level, processing time |
{
"success": true,
"analysis": {
"summary": "This plan creates 2 resources and destroys 1. The main risk is a database deletion (aws_db_instance.main) which cannot be undone.",
"risk_flags": [
{
"severity": "HIGH",
"resource": "aws_db_instance.main",
"message": "Database deletion — data will be permanently lost"
}
],
"reviewer_checklist": [
"Confirm database backup was taken before merging",
"Verify deletion protection is intentionally disabled"
],
"pr_markdown": "## 🔍 Terraform Plan Summary\n\n**2 to add** | **0 to change** | **1 to destroy**\n\n..."
},
"metadata": {
"resources_total": 3,
"resources_created": 2,
"resources_updated": 0,
"resources_destroyed": 1,
"max_risk_level": "HIGH",
"processing_time_ms": 312
}
}
POST /api/keys
Generate an API key. No authentication required.
Creates a new API key tied to an email address. Returns the key once — store it immediately.
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Your email address. Used to group keys and enforce the 5-key limit. | |
| name | string | optional | A label for this key, e.g. "ci-prod" or "local-dev" |
GET /api/keys/verify
Verify an API key is valid and active. Useful for CI pipeline health checks.
Returns key metadata including prefix and creation date.
GET /api/example
Returns a sample Terraform plan JSON for testing. No authentication required.
Good for testing your integration without a real Terraform plan.
curl https://planplain.polsia.app/api/example | \
curl -X POST https://planplain.polsia.app/api/analyze \
-H "Authorization: Bearer pp_live_..." \
-H "Content-Type: application/json" \
-d @-
Code Examples
Full pipeline — bash script
#!/usr/bin/env bash
# planplain-analyze.sh — analyze a terraform plan and print the summary
API_KEY="pp_live_your_key_here"
PLAN_FILE="plan.json"
# Generate plan JSON
terraform plan -out=tfplan
terraform show -json tfplan > "$PLAN_FILE"
# Send to PlanPlain
RESPONSE=$(curl -s -X POST https://planplain.polsia.app/api/analyze \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d @"$PLAN_FILE")
# Print summary
echo "$RESPONSE" | jq -r '.analysis.summary'
# Print risk flags
echo "$RESPONSE" | jq -r '.analysis.risk_flags[] | "[\(.severity)] \(.resource): \(.message)"'
# Print PR markdown
echo "$RESPONSE" | jq -r '.analysis.pr_markdown'
Node.js / TypeScript
// analyze-plan.js — Node.js 18+ (native fetch)
const fs = require('fs');
async function analyzePlan(planFilePath) {
const planJson = JSON.parse(fs.readFileSync(planFilePath, 'utf8'));
const response = await fetch('https://planplain.polsia.app/api/analyze', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PLANPLAIN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(planJson),
});
if (!response.ok) {
const err = await response.json();
throw new Error(`PlanPlain error: ${err.message}`);
}
const { analysis, metadata } = await response.json();
console.log('Summary:', analysis.summary);
console.log('Risk level:', metadata.max_risk_level);
console.log('Risk flags:', analysis.risk_flags.length);
// Post pr_markdown as a GitHub PR comment (example)
return analysis.pr_markdown;
}
analyzePlan('./plan.json')
.then(markdown => console.log(markdown))
.catch(console.error);
TypeScript (with types)
interface RiskFlag {
severity: 'HIGH' | 'MED' | 'LOW';
resource: string;
message: string;
}
interface PlanPlainResponse {
success: boolean;
analysis: {
summary: string;
risk_flags: RiskFlag[];
reviewer_checklist: string[];
pr_markdown: string;
};
metadata: {
resources_total: number;
resources_created: number;
resources_updated: number;
resources_destroyed: number;
max_risk_level: string;
processing_time_ms: number;
};
}
async function analyzePlan(planJson: object): Promise<PlanPlainResponse> {
const res = await fetch('https://planplain.polsia.app/api/analyze', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PLANPLAIN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(planJson),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<PlanPlainResponse>;
}
Python
# analyze_plan.py — Python 3.7+ (no extra dependencies)
import json
import os
import urllib.request
def analyze_plan(plan_file_path: str) -> dict:
# Load the plan JSON
with open(plan_file_path) as f:
plan_json = json.load(f)
api_key = os.environ["PLANPLAIN_API_KEY"]
url = "https://planplain.polsia.app/api/analyze"
req = urllib.request.Request(
url,
data=json.dumps(plan_json).encode(),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req) as response:
result = json.loads(response.read())
return result
# Usage
result = analyze_plan("plan.json")
analysis = result["analysis"]
print(f"Summary: {analysis['summary']}")
print(f"Risk flags: {len(analysis['risk_flags'])}")
for flag in analysis["risk_flags"]:
print(f" [{flag['severity']}] {flag['resource']}: {flag['message']}")
print("\nPR Markdown:")
print(analysis["pr_markdown"])
With requests library
import requests
import json, os
with open("plan.json") as f:
plan = json.load(f)
response = requests.post(
"https://planplain.polsia.app/api/analyze",
headers={"Authorization": f"Bearer {os.environ['PLANPLAIN_API_KEY']}"},
json=plan,
timeout=60,
)
response.raise_for_status()
data = response.json()
print(data["analysis"]["pr_markdown"])
GitHub Actions Integration
Use the official PlanPlain GitHub Action to automatically comment on PRs whenever Terraform files change.
name: Terraform Plan Review
on:
pull_request:
paths: ['**.tf', '**.tfvars']
permissions:
pull-requests: write
jobs:
terraform-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- name: Generate plan JSON
run: |
terraform init
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Analyze with PlanPlain
uses: Polsia-Inc/planplain/github-action@main
with:
plan_file: plan.json
api_key: ${{ secrets.PLANPLAIN_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
post_comment: true
PLANPLAIN_API_KEY.Error Codes
All errors follow the same format: { "success": false, "error": "CODE", "message": "description" }
| HTTP | Error Code | Description |
|---|---|---|
| 400 | INVALID_PLAN | Request body is not valid Terraform plan JSON. Missing resource_changes. |
| 401 | MISSING_API_KEY | No Authorization header provided. |
| 401 | INVALID_API_KEY | Key not found or has been deactivated. |
| 429 | RATE_LIMITED | Too many requests. Check Retry-After header for wait time (seconds). |
| 500 | ANALYSIS_FAILED | Internal error during analysis. Heuristic fallback still runs — partial results may be available. |
{
"success": false,
"error": "RATE_LIMITED",
"message": "Rate limit exceeded. Try again in 42 seconds.",
"retry_after": 42
}