API Reference
← Back to home Dashboard Get API Key
Documentation

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.

1
Get an API key

Generate a free API key with your email. No account signup required.

bash
curl -X POST https://dev.api.plainplan.click/api/keys \
  -H "Content-Type: application/json" \
  -d '{"email": "you@yourcompany.com"}'
response
{
  "success": true,
  "api_key": "pp_live_a1b2c3d4e5f6..."  // save this — shown once
}
2
Export your Terraform plan as JSON

Run these commands in your Terraform directory to get the plan JSON.

bash
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
3
Analyze the plan

POST the JSON file to /api/analyze. Read analysis.decision first. Request markdown or flags only when you need them.

bash
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.

✓
Check your usage

See requests, error rate, response times, and rate limit status in real time.

View your dashboard →


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.

http 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
⚠️
Keep your key secret. It's shown once at creation time. If you lose it, generate a new one — old keys stay active.

Rate Limits

Rate limits are per API key using a sliding window.

Window
Limit
Scope
1 min
rolling window
10
requests
per API key
24 hrs
rolling window
200
requests
per API key

When rate limited, the API returns HTTP 429 with a Retry-After header (seconds to wait).

Rate limit headers

response 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.

DecisionWhenCaller 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.
ℹ️
ALLOW means no significant finding under this first AWS-focused policy. Cost checks are not in the gate. See the repo 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.

POST /api/analyze 🔑 Auth required

Accepts terraform show -json tfplan. Gate fields and analysis.receipt are always returned. Optional fields use include.

Query parameters

FieldTypeRequiredDescription
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.

FieldTypeRequiredDescription
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.
💡
Tip: Send the entire file as the request body — no wrapping needed. The plan JSON from terraform show -json is accepted directly.

Response body

FieldTypeDescription
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
200 OK
{
  "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
    }
}
200 OK with 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.

POST /api/keys No auth

Creates a new API key tied to an email address. Returns the key once — store it immediately.

FieldTypeRequiredDescription
email 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.

GET /api/keys/verify 🔑 Auth required

Returns key metadata including prefix and creation date.


GET /api/example

Returns a sample Terraform plan JSON for testing. No authentication required.

GET /api/example No auth

Good for testing your integration without a real Terraform plan.

bash
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.

POST /mcp 🔑 Auth required for tools/call

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.

json-rpc
{
  "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.

POST /api/free/analyze GitHub OIDC

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

bash
#!/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

javascript
// 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)

typescript
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

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

python
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.

.github/workflows/terraform-review.yml
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
InputDefaultNotes
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.
🔑
Private repos: add 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.

bash
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.

Install — Linux, macOS, WSL
curl -fsSL https://raw.githubusercontent.com/adamnmcc/plainplan-cli/v1/install.sh | sh
Run it
# 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
💡
Runs in demo mode with no key (heuristics only). For the full AI review, set 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" }

HTTPError CodeDescription
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.
429 Too Many Requests
{
  "success": false,
  "error": "RATE_LIMITED",
  "message": "Rate limit exceeded. Try again in 42 seconds.",
  "retry_after": 42
}
PlainPlan · API v1.0
Questions? hello@plainplan.click