PlainPlan API Reference
Send terraform show -json. Every successful analysis includes a deterministic gate — ALLOW, REVIEW or BLOCK — plus an unsigned receipt bound to the plan hash. The model writes the summary. It cannot set severity or change the decision.
Optional fields (risk_flags, reviewer_checklist, pr_markdown, resources) are still requested with include. Gate fields are always present, including no-change and AI-fallback responses.
Base URL: https://dev.api.plainplan.click
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://dev.api.plainplan.click/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. Read analysis.decision first. Request markdown or flags only when you need them.
curl -X POST 'https://dev.api.plainplan.click/api/analyze?include=pr_markdown' \
-H "Authorization: Bearer pp_live_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d @plan.json
analysis.decision, risk_score, findings and receipt are always returned. With include=pr_markdown you also get analysis.pr_markdown for the PR comment.
See requests, error rate, response times, and rate limit status in real time.
Authentication
API-key callers send a Bearer token. Public GitHub Actions use OIDC on /api/free/analyze instead. Agents can call the same gate over POST /mcp, or pay per analysis with x402 when that rail is enabled.
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
Risk gate
Policy 2026-09-23.2 is deterministic. Same plan + policy → same decision on REST, MCP, free tier and demo. The LLM cannot add unsupported facts, override policy, or turn BLOCK into REVIEW.
| Decision | When | Caller should |
|---|---|---|
| ALLOW | No significant finding under this limited policy. Named-safe changes (tags, capacity, CloudWatch observability creates, proven IAM/network narrowing) can reach ALLOW. Unnamed changes stay closed. | May apply automatically. Not a guarantee of safety. |
| REVIEW | High/medium findings, incomplete evidence, or coverage gaps. Unknown provider-assigned IDs (id, arn) do not force review; unknown policy/ingress values do. |
Escalate to a human. Keep the issued REVIEW; do not rewrite it after approval. |
| BLOCK | Critical: stateful delete/replace (RDS, S3, EBS/EFS, DynamoDB, ElastiCache, OpenSearch, KMS), unrestricted IAM admin, or public ingress on sensitive ports. | Stop. Revise the plan and assess the new one. |
docs/risk-gate.md for the full rule list.POST /api/analyze
Analyzes Terraform plan JSON and returns the gate, a summary, and optional review fields.
Accepts terraform show -json tfplan. Gate fields and analysis.receipt are always returned. Optional fields use include.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| include | string | optional | Comma-separated or repeated field names to include in analysis: risk_flags, reviewer_checklist, pr_markdown, resources, or all. |
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 | Explanation of what the plan does. Always returned. The model may write this; it cannot change the gate. |
| analysis.decision | string | Always returned. ALLOW, REVIEW, or BLOCK. Independent of include. |
| analysis.requires_human | boolean | true for REVIEW and BLOCK. A human approving REVIEW does not rewrite the issued decision. |
| analysis.risk_score | integer | Maximum finding severity: critical 100, high 75, medium 40, low 10, none 0. Policy score, not an incident probability. |
| analysis.findings | array | Always returned. Each item is { severity, category, resource, message }. |
| analysis.blast_radius | object | Counts: created, updated, replaced, destroyed, stateful_affected, security_sensitive_affected. Replacements counted once. |
| analysis.policy_version | string | Currently 2026-09-23.2. |
| analysis.receipt | object | Always returned. assessment_id, full-plan sha256: digest, decision, score, checks, policy/receipt versions, signed: false. Not stored server-side; does not prove apply or safety. |
| analysis.risk_flags | array | Returned only when requested via include=risk_flags. Array of { severity, resource, message } objects. |
| analysis.reviewer_checklist | array | Returned only when requested via include=reviewer_checklist. |
| analysis.pr_markdown | string | Returned only when requested via include=pr_markdown. |
| analysis.resources | object | Returned only when requested via include=resources. Object with created, updated, destroyed, replaced arrays, each containing { address, type, provider, changed_fields? } objects. |
| metadata | object | Resource counts, max risk level, processing time |
{
"success": true,
"analysis": {
"summary": "This plan destroys aws_db_instance.production and replaces the API instance.",
"decision": "BLOCK",
"requires_human": true,
"risk_score": 100,
"policy_version": "2026-09-23.2",
"findings": [
{
"severity": "critical",
"category": "destructive_change",
"resource": "aws_db_instance.production",
"message": "Stateful resource will be deleted or replaced"
}
],
"receipt": {
"assessment_id": "ppa_…",
"plan_hash": "sha256:…",
"signed": false
}
},
"metadata": {
"resources_total": 3,
"resources_created": 2,
"resources_updated": 0,
"resources_destroyed": 1,
"max_risk_level": "HIGH",
"processing_time_ms": 312
}
}
include=all
{
"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://dev.api.plainplan.click/api/example | \
curl -X POST 'https://dev.api.plainplan.click/api/analyze?include=pr_markdown' \
-H "Authorization: Bearer pp_live_..." \
-H "Content-Type: application/json" \
-d @-
POST /mcp
Stateless MCP over HTTP. Same API key, rate limits and metering as REST. One JSON-RPC 2.0 request per call. No SSE streams, sessions or batches. GET /mcp returns 405.
Methods: initialize, tools/list, tools/call, ping, and notifications (202, empty body). Discovery is free. Only tools/call is metered.
The tool is analyze_terraform_plan({plan, environment, metadata}). environment and metadata are recorded on the receipt only — they never change the decision. A successful call returns the same gate contract in structuredContent.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "analyze_terraform_plan",
"arguments": {
"plan": { "resource_changes": [] },
"environment": "production"
}
}
}
An unreadable plan returns isError: true with INVALID_PLAN and no decision. Unassessed is never ALLOW.
POST /api/free/analyze
Same analysis as /api/analyze for public GitHub repositories. Auth is a GitHub Actions OIDC token in X-GitHub-OIDC, not an API key. Private repos must use an API key.
The Action requests this automatically when api_key is omitted. The job needs permissions: id-token: write. Default audience is plainplan.
Per-repo limits default to 5/minute and 50/day, with a global free-tier budget of 3000/day. These are operator-configured, not a paid-plan entitlement.
Code Examples
Full pipeline — bash script
#!/usr/bin/env bash
# plainplan-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 PlainPlan
RESPONSE=$(curl -s -X POST 'https://dev.api.plainplan.click/api/analyze?include=all' \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d @"$PLAN_FILE")
# Gate first — the model cannot change these fields
echo "$RESPONSE" | jq -r '"\(.analysis.decision) score=\(.analysis.risk_score) \(.analysis.receipt.plan_hash)"'
# Print summary
echo "$RESPONSE" | jq -r '.analysis.summary'
# Print findings
echo "$RESPONSE" | jq -r '.analysis.findings[] | "[\(.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://dev.api.plainplan.click/api/analyze?include=risk_flags,pr_markdown', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PLAINPLAN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(planJson),
});
if (!response.ok) {
const err = await response.json();
throw new Error(`PlainPlan error: ${err.message}`);
}
const { analysis, metadata } = await response.json();
console.log('Decision:', analysis.decision, analysis.risk_score);
console.log('Summary:', analysis.summary);
console.log('Findings:', analysis.findings.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 Finding {
severity: 'critical' | 'high' | 'medium' | 'low';
category: string;
resource: string;
message: string;
}
interface PlainPlanResponse {
success: boolean;
analysis: {
summary: string;
decision: 'ALLOW' | 'REVIEW' | 'BLOCK';
requires_human: boolean;
risk_score: number;
findings: Finding[];
policy_version: string;
receipt: { assessment_id: string; plan_hash: string; signed: boolean };
risk_flags?: { severity: string; resource: string; message: string }[];
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<PlainPlanResponse> {
const res = await fetch('https://dev.api.plainplan.click/api/analyze?include=risk_flags,pr_markdown', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PLAINPLAN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(planJson),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<PlainPlanResponse>;
}
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["PLAINPLAN_API_KEY"]
url = "https://dev.api.plainplan.click/api/analyze?include=all"
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"Decision: {analysis['decision']} score={analysis['risk_score']}")
print(f"Summary: {analysis['summary']}")
print(f"Findings: {len(analysis['findings'])}")
for finding in analysis["findings"]:
print(f" [{finding['severity']}] {finding['resource']}: {finding['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://dev.api.plainplan.click/api/analyze?include=pr_markdown",
headers={"Authorization": f"Bearer {os.environ['PLAINPLAN_API_KEY']}"},
json=plan,
timeout=60,
)
response.raise_for_status()
data = response.json()
print(data["analysis"]["pr_markdown"])
GitHub Actions Integration
Official Action: adamnmcc/plainplan-action@v1. It posts a PR comment and can publish a plainplan commit status. Public repos can omit the API key.
name: Terraform Plan Review
on:
pull_request:
paths: ['**.tf', '**.tfvars']
jobs:
terraform-review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
id-token: write
statuses: write
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
- name: Analyze with PlainPlan
uses: adamnmcc/plainplan-action@v1
with:
plan_file: plan.json
fail_on_risk: HIGH
| Input | Default | Notes |
|---|---|---|
| api_key | empty | Required for private repos. Public repos use OIDC when this is omitted. |
| fail_on_risk | none | none | LOW | MED | HIGH. Fails the job and sets the plainplan status when the Action's risk level meets the threshold. Needs statuses: write. |
| post_comment | true | Post or skip the PR comment. |
PLAINPLAN_API_KEY under Settings → Secrets → Actions. Public repos: skip the secret and keep id-token: write.Agent CLI
examples/agent_gate.mjs is a small CLI: it sends a plan to POST /mcp and exits 0 for ALLOW, 75 for REVIEW, and 1 for BLOCK or an unreadable plan. It never runs terraform apply.
terraform plan -out tfplan
terraform show -json tfplan > plan.json
PLAINPLAN_API_KEY=pp_live_... node examples/agent_gate.mjs plan.json
Optional environment: PLAINPLAN_API_BASE_URL, PLAINPLAN_ENVIRONMENT, PLAINPLAN_AGENT, PLAINPLAN_SESSION_ID. GITHUB_REPOSITORY and GITHUB_SHA are recorded on the receipt when present. None of them changes the decision. A transport or HTTP error exits as BLOCK so an unassessed plan is never treated as ALLOW.
Command-Line (CLI)
Review a Terraform plan locally, before you push — the same summary and risk flags in your terminal. Great in a pre-push hook to catch risky changes before they hit CI.
curl -fsSL https://raw.githubusercontent.com/adamnmcc/plainplan-cli/v1/install.sh | sh
# runs `terraform plan` for you, then reviews it
plainplan run --fail-on-risk HIGH
# or feed it a plan you already have
terraform show -json tfplan | plainplan
PLAINPLAN_API_KEY. Requires curl and jq. Source: github.com/adamnmcc/plainplan-cli.x402 payments
Experimental. An agent with no API key can buy one analysis. Humans, CI and the GitHub Action keep using API keys; that path is tried first. Off by default until X402_ENABLED is set.
Unauthenticated POST /api/analyze or POST /mcp returns HTTP 402 with payment requirements priced by plan size: $0.05 / $0.10 / $0.25. Settlement happens after the analysis and before the result is released. Replay of a settled authorization is refused. MCP discovery is free.
Verified end-to-end on base-sepolia only. Mainnet rails are not a production claim. See repo docs/payments.md.
Data handling
Terraform JSON can include plaintext sensitive values. The current API accepts the plan you send, computes the gate from that JSON, and may send the plan to a model subprocessor for the summary. Client-side sanitisation is not shipped yet. Do not treat the demo or API as a secret store.
Receipts hash the canonical submitted plan. They are unsigned and not stored for lookup. We do not claim zero retention. The gate does not use the model; the summary does.
Error Codes
All errors follow the same format: { "success": false, "error": "CODE", "message": "description" }
| HTTP | Error Code | Description |
|---|---|---|
| 400 | INVALID_INPUT | Body is not a non-empty JSON object. |
| 402 | x402 challenge | No API key and x402 is enabled. Body lists payment requirements. Not the usual {success,error,message} shape. |
| 401 | MISSING_API_KEY / INVALID_API_KEY | No usable API key, and x402 is not offering a challenge. |
| 422 | INVALID_PLAN | JSON parsed but is not a readable Terraform plan. |
| 429 | RATE_LIMITED | Too many requests. Check Retry-After (seconds). |
| 500 | INTERNAL_ERROR | Unexpected server error. Gate fallback still runs when the model fails; that is a 200 with heuristic summary, not this code. |
{
"success": false,
"error": "RATE_LIMITED",
"message": "Rate limit exceeded. Try again in 42 seconds.",
"retry_after": 42
}