Skip to main content
Back to blog
April 9, 2026·Updated May 2, 2026|11 min read|Antoine Duno|Devops Security

How to Automate Website Security Scans with a REST API

Running a security scan manually once a month is better than never, but it is not monitoring — it is archaeology. This guide walks through using ZeriFlow's REST API to automate security scanning: authenticate with X-API-Key, parse the JSON response, schedule scans with cron, integrate with alerting systems, and handle errors and rate limits properly.

Antoine Duno

1,850 words

AD

Antoine Duno

Founder of ZeriFlow · 10 years fullstack engineering · About the author

Key Takeaways

  • Running a security scan manually once a month is better than never, but it is not monitoring — it is archaeology. This guide walks through using ZeriFlow's REST API to automate security scanning: authenticate with X-API-Key, parse the JSON response, schedule scans with cron, integrate with alerting systems, and handle errors and rate limits properly.
  • Includes copy-paste code examples and step-by-step instructions.
  • Free automated scan available to verify your implementation.

How to Automate Website Security Scans with a REST API

Manual security scans generate reports. Automated security scans generate continuous awareness. The difference is whether you know about a configuration regression within hours of it happening, or whether you find out when a customer mentions it.

ZeriFlow provides a REST API that makes it straightforward to integrate security scanning into any automation pipeline — a cron job on a VPS, a serverless function on a schedule, a post-deploy hook in your CI/CD pipeline, or a custom monitoring dashboard. This guide covers the complete implementation from authentication to alerting.


The ZeriFlow API: Core Concepts

Base URL: https://api.zeriflow.com

Authentication: every request requires an X-API-Key header with your API key from the ZeriFlow dashboard (Settings > API Keys).

Plan limits: - Pro: 30 API calls/month - Business: 100 API calls/month - Unlimited: 1,000 API calls/month

Rate limiting: the API enforces per-minute rate limits in addition to monthly quotas. Respect Retry-After headers on 429 responses.


The /scan-quick Endpoint

POST /scan-quick is the primary scanning endpoint. It runs 80+ checks against a target URL and returns a complete JSON report within 60 seconds.

Request

bash
curl -X POST https://api.zeriflow.com/scan-quick \\
  -H "Content-Type: application/json" \\
  -H "X-API-Key: zf_live_your_api_key_here" \\
  -d ''{
    "url": "https://your-app.com"
  }''

Response Structure

json
{
  "scan_id": "scan_01hwxyz789abc",
  "url": "https://your-app.com",
  "score": 74,
  "grade": "C+",
  "scanned_at": "2026-05-02T09:14:32Z",
  "scan_duration_ms": 8420,
  "summary": {
    "total_checks": 83,
    "passed": 61,
    "failed": 22,
    "critical": 1,
    "high": 3,
    "medium": 9,
    "low": 6,
    "informational": 3
  },
  "findings": [
    {
      "id": "finding_csp_missing",
      "check_id": "content-security-policy",
      "title": "Content Security Policy header is missing",
      "severity": "critical",
      "category": "http-headers",
      "description": "The Content-Security-Policy header is not present. This header helps prevent XSS attacks by specifying which sources of content the browser should allow.",
      "remediation": "Add a Content-Security-Policy header to your server responses. Start with a strict policy: Content-Security-Policy: default-src ''self''",
      "references": [
        "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy",
        "https://owasp.org/www-project-secure-headers/"
      ],
      "cvss_score": null,
      "passed": false
    },
    {
      "id": "finding_hsts_present",
      "check_id": "strict-transport-security",
      "title": "Strict-Transport-Security header is present",
      "severity": "high",
      "category": "http-headers",
      "description": null,
      "remediation": null,
      "passed": true
    }
  ],
  "tls": {
    "valid": true,
    "issuer": "Let''s Encrypt",
    "expires_at": "2026-08-01T00:00:00Z",
    "days_until_expiry": 91,
    "protocol_versions": ["TLSv1.2", "TLSv1.3"],
    "grade": "A"
  },
  "headers_found": ["Strict-Transport-Security", "X-Frame-Options", "Referrer-Policy"],
  "headers_missing": ["Content-Security-Policy", "Permissions-Policy"],
  "report_url": "https://zeriflow.com/reports/scan_01hwxyz789abc"
}

Parsing the Response in Shell

bash
#!/bin/bash
# scan.sh

URL="${1:-https://your-app.com}"
THRESHOLD="${2:-75}"

RESPONSE=$(curl -s -w "\\n%{http_code}" \\
  -X POST https://api.zeriflow.com/scan-quick \\
  -H "Content-Type: application/json" \\
  -H "X-API-Key: $ZERIFLOW_API_KEY" \\
  -d "{\\"url\\": \\"$URL\\"}")

HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed ''$d'')

# Handle HTTP errors
case "$HTTP_CODE" in
  200) ;;
  401) echo "ERROR: Invalid API key"; exit 2 ;;
  422) echo "ERROR: Invalid URL format"; exit 3 ;;
  429) 
    RETRY=$(echo "$RESPONSE" | grep -i ''retry-after'' | awk ''{print $2}'' | tr -d ''\\r'')
    echo "ERROR: Rate limited. Retry after ${RETRY:-60} seconds"
    exit 4 ;;
  5*)  echo "ERROR: ZeriFlow API error (HTTP $HTTP_CODE)"; exit 5 ;;
  *)   echo "ERROR: Unexpected response (HTTP $HTTP_CODE)"; exit 6 ;;
esac

# Extract values
SCORE=$(echo "$BODY" | jq -r ''.score'')
CRITICAL=$(echo "$BODY" | jq -r ''.summary.critical'')
HIGH=$(echo "$BODY" | jq -r ''.summary.high'')
REPORT_URL=$(echo "$BODY" | jq -r ''.report_url'')
CERT_EXPIRY=$(echo "$BODY" | jq -r ''.tls.days_until_expiry'')

echo "Score: $SCORE/100"
echo "Critical findings: $CRITICAL"
echo "High findings: $HIGH"
echo "Certificate expires in: $CERT_EXPIRY days"
echo "Full report: $REPORT_URL"

# List critical and high findings
echo ""
echo "Issues requiring attention:"
echo "$BODY" | jq -r ''
  .findings[] | 
  select(.passed == false and (.severity == "critical" or .severity == "high")) |
  "  [\\(.severity | ascii_upcase)] \\(.title)"
''

# Exit code based on threshold
if (( $(echo "$SCORE < $THRESHOLD" | bc -l) )); then
  echo ""
  echo "FAIL: Score $SCORE is below threshold $THRESHOLD"
  exit 1
fi

echo ""
echo "PASS: Score $SCORE meets threshold $THRESHOLD"
exit 0

Parsing the Response in Python

python
#!/usr/bin/env python3
# scan.py

import os
import sys
import json
import time
import requests
from datetime import datetime

def scan_url(url: str, threshold: int = 75) -> dict:
    api_key = os.environ.get(''ZERIFLOW_API_KEY'')
    if not api_key:
        raise ValueError("ZERIFLOW_API_KEY environment variable is not set")

    response = requests.post(
        ''https://api.zeriflow.com/scan-quick'',
        headers={
            ''Content-Type'': ''application/json'',
            ''X-API-Key'': api_key
        },
        json={''url'': url},
        timeout=90  # scans can take up to 60 seconds
    )

    if response.status_code == 429:
        retry_after = int(response.headers.get(''Retry-After'', 60))
        raise Exception(f"Rate limited. Retry after {retry_after} seconds.")

    response.raise_for_status()
    return response.json()


def format_findings(data: dict) -> str:
    critical_high = [
        f for f in data[''findings'']
        if not f[''passed''] and f[''severity''] in (''critical'', ''high'')
    ]

    if not critical_high:
        return "No critical or high findings."

    lines = []
    for finding in critical_high:
        lines.append(f"[{finding[''severity''].upper()}] {finding[''title'']}")
        if finding.get(''remediation''):
            lines.append(f"  Fix: {finding[''remediation''][:120]}...")
    return ''\\n''.join(lines)


def main():
    url = sys.argv[1] if len(sys.argv) > 1 else ''https://your-app.com''
    threshold = int(sys.argv[2]) if len(sys.argv) > 2 else 75

    try:
        print(f"Scanning: {url}")
        data = scan_url(url, threshold)

        score = data[''score'']
        cert_days = data[''tls''][''days_until_expiry'']
        report_url = data[''report_url'']

        print(f"Score: {score}/100")
        print(f"Certificate expires in: {cert_days} days")
        print(f"Full report: {report_url}")
        print()
        print("Findings requiring attention:")
        print(format_findings(data))

        # Cert expiry warning
        if cert_days < 30:
            print(f"\\nWARNING: Certificate expires in {cert_days} days!")

        if score < threshold:
            print(f"\\nFAIL: Score {score} is below threshold {threshold}")
            sys.exit(1)

        print(f"\\nPASS: Score {score} meets threshold {threshold}")

    except Exception as e:
        print(f"Scan error: {e}", file=sys.stderr)
        sys.exit(2)


if __name__ == ''__main__'':
    main()

Scheduling Scans with Cron (Linux/VPS)

For a simple production setup on any Linux server, cron provides reliable scheduled execution. Store your scripts in a consistent location and log output for debugging.

bash
# Create the scanner script
mkdir -p /opt/zeriflow
cat > /opt/zeriflow/scan.sh << ''EOF''
#!/bin/bash
# Load API key from a secure location
export ZERIFLOW_API_KEY=$(cat /etc/zeriflow/api_key)

LOG_FILE="/var/log/zeriflow/scan-$(date +%Y%m%d).log"
mkdir -p /var/log/zeriflow

echo "=== Scan started: $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE"

# Run the scan
/opt/zeriflow/scan-and-alert.sh "https://your-app.com" "75" >> "$LOG_FILE" 2>&1
EXIT_CODE=$?

echo "=== Scan completed: exit $EXIT_CODE ===" >> "$LOG_FILE"

# Rotate logs older than 30 days
find /var/log/zeriflow -name "*.log" -mtime +30 -delete
EOF

chmod +x /opt/zeriflow/scan.sh

# Store API key securely
mkdir -p /etc/zeriflow
echo "zf_live_your_api_key_here" > /etc/zeriflow/api_key
chmod 600 /etc/zeriflow/api_key

# Add to crontab: run daily at 2 AM
echo "0 2 * * * /opt/zeriflow/scan.sh" | crontab -

For multi-URL monitoring:

bash
# /opt/zeriflow/multi-scan.sh
#!/bin/bash

export ZERIFLOW_API_KEY=$(cat /etc/zeriflow/api_key)

URLS=(
  "https://your-app.com:80"
  "https://api.your-app.com:75"
  "https://admin.your-app.com:85"
)

FAILED=0

for ENTRY in "${URLS[@]}"; do
  URL="${ENTRY%%:*}"
  THRESHOLD="${ENTRY##*:}"

  echo "Scanning $URL (threshold: $THRESHOLD)..."
  /opt/zeriflow/scan-and-alert.sh "$URL" "$THRESHOLD" || FAILED=$((FAILED + 1))
  sleep 5  # avoid hitting rate limits
done

if [ "$FAILED" -gt 0 ]; then
  echo "$FAILED scan(s) failed threshold check"
  exit 1
fi

Integrating with Slack and PagerDuty

Build a complete scan-and-alert script that routes to the right channel based on severity:

bash
#!/bin/bash
# /opt/zeriflow/scan-and-alert.sh

URL="$1"
THRESHOLD="${2:-75}"
SLACK_WEBHOOK="${SLACK_SECURITY_WEBHOOK}"
PAGERDUTY_KEY="${PAGERDUTY_INTEGRATION_KEY}"

RESPONSE=$(curl -s \\
  -X POST https://api.zeriflow.com/scan-quick \\
  -H "Content-Type: application/json" \\
  -H "X-API-Key: $ZERIFLOW_API_KEY" \\
  -d "{\\"url\\": \\"$URL\\"}")

SCORE=$(echo "$RESPONSE" | jq -r ''.score'')
CRITICAL=$(echo "$RESPONSE" | jq -r ''.summary.critical'')
REPORT_URL=$(echo "$RESPONSE" | jq -r ''.report_url'')

notify_slack() {
  local message="$1"
  local color="$2"
  curl -s -X POST "$SLACK_WEBHOOK" \\
    -H "Content-Type: application/json" \\
    -d "{
      \\"attachments\\": [{
        \\"color\\": \\"$color\\",
        \\"text\\": \\"$message\\",
        \\"footer\\": \\"ZeriFlow | $(date -u +%Y-%m-%dT%H:%M:%SZ)\\"
      }]
    }"
}

trigger_pagerduty() {
  local summary="$1"
  curl -s -X POST "https://events.pagerduty.com/v2/enqueue" \\
    -H "Content-Type: application/json" \\
    -d "{
      \\"routing_key\\": \\"$PAGERDUTY_KEY\\",
      \\"event_action\\": \\"trigger\\",
      \\"dedup_key\\": \\"zeriflow-$(echo "$URL" | md5sum | head -c8)-$(date +%Y%m%d)\\",
      \\"payload\\": {
        \\"summary\\": \\"$summary\\",
        \\"severity\\": \\"critical\\",
        \\"source\\": \\"zeriflow\\",
        \\"custom_details\\": {
          \\"url\\": \\"$URL\\",
          \\"score\\": $SCORE,
          \\"critical_findings\\": $CRITICAL,
          \\"report\\": \\"$REPORT_URL\\"
        }
      }
    }"
}

# Route alerts based on severity
if [ "$CRITICAL" -gt 0 ]; then
  trigger_pagerduty "$CRITICAL critical security finding(s) on $URL — score: $SCORE/100"
  notify_slack "CRITICAL: $CRITICAL critical finding(s) on $URL — score $SCORE/100. Report: $REPORT_URL" "danger"
elif (( $(echo "$SCORE < $THRESHOLD" | bc -l) )); then
  notify_slack "Security score dropped to $SCORE/100 on $URL (threshold: $THRESHOLD). Report: $REPORT_URL" "warning"
else
  echo "OK: $URL scored $SCORE/100 (threshold: $THRESHOLD)"
fi

Error Handling and Retry Logic

Network errors, API timeouts, and rate limits need to be handled gracefully in any production script. Implement exponential backoff:

bash
#!/bin/bash
# Wrapper with retry logic

scan_with_retry() {
  local url="$1"
  local max_attempts=3
  local attempt=1
  local delay=10

  while [ $attempt -le $max_attempts ]; do
    RESPONSE=$(curl -s -w "\\n%{http_code}" \\
      --connect-timeout 10 \\
      --max-time 90 \\
      -X POST https://api.zeriflow.com/scan-quick \\
      -H "Content-Type: application/json" \\
      -H "X-API-Key: $ZERIFLOW_API_KEY" \\
      -d "{\\"url\\": \\"$url\\"}")

    HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
    BODY=$(echo "$RESPONSE" | sed ''$d'')

    if [ "$HTTP_CODE" = "200" ]; then
      echo "$BODY"
      return 0
    fi

    if [ "$HTTP_CODE" = "429" ]; then
      # Extract Retry-After header from body (ZeriFlow includes it in JSON too)
      RETRY_AFTER=$(echo "$BODY" | jq -r ''.retry_after // 60'')
      echo "Rate limited. Waiting ${RETRY_AFTER}s before retry..." >&2
      sleep "$RETRY_AFTER"
      attempt=$((attempt + 1))
      continue
    fi

    echo "Attempt $attempt/$max_attempts failed (HTTP $HTTP_CODE). Retrying in ${delay}s..." >&2
    sleep $delay
    delay=$((delay * 2))  # exponential backoff
    attempt=$((attempt + 1))
  done

  echo "All $max_attempts attempts failed" >&2
  return 1
}

RESULT=$(scan_with_retry "https://your-app.com")
if [ $? -eq 0 ]; then
  echo "Score: $(echo "$RESULT" | jq -r ''.score'')"
else
  echo "Scan failed after retries"
  exit 1
fi

Managing Monthly API Quota

On the Pro plan (30 calls/month), budget your calls carefully:

Use caseCalls needed
Daily scan of 1 URL31 calls/month
Post-deploy scan (20 deploys/month)20 calls/month
CI/CD check on every PR (estimate)15-40 calls/month

For quota-conscious setups, check the scan result against a cache before spending an API call:

bash
#!/bin/bash
# Only scan if config files have changed since last scan

CACHE_DIR="/var/cache/zeriflow"
mkdir -p "$CACHE_DIR"

# Hash of your security-relevant config files
CONFIG_HASH=$(find /your/project -name "nginx.conf" -o -name "next.config.js" -o -name "Caddyfile" | \\
  sort | xargs sha256sum 2>/dev/null | sha256sum | head -c16)

CACHE_FILE="$CACHE_DIR/last-scan-$CONFIG_HASH.json"

if [ -f "$CACHE_FILE" ]; then
  AGE=$(($(date +%s) - $(stat -c%Y "$CACHE_FILE")))
  if [ "$AGE" -lt 86400 ]; then
    echo "Using cached scan result (${AGE}s old)"
    cat "$CACHE_FILE"
    exit 0
  fi
fi

# Run fresh scan
RESULT=$(curl -s \\
  -X POST https://api.zeriflow.com/scan-quick \\
  -H "X-API-Key: $ZERIFLOW_API_KEY" \\
  -H "Content-Type: application/json" \\
  -d ''{"url": "https://your-app.com"}'')

echo "$RESULT" > "$CACHE_FILE"
echo "$RESULT"

Conclusion

The ZeriFlow REST API makes it straightforward to integrate security scanning into any part of your infrastructure stack — cron jobs, post-deploy hooks, CI pipelines, or custom dashboards. The JSON response is structured for easy parsing, and the endpoints are designed for automation rather than interactive use.

Start with a cron job that runs daily and sends Slack alerts on score drops. Once you have baseline data and understand your typical score range, add threshold-based PagerDuty escalation for critical findings.

ZeriFlow Pro includes 30 API calls/month — enough for daily scanning, with headroom for CI/CD integration on actively developed projects.

Ready to check your site?

Run a free security scan in 30 seconds.

Related articles

Keep reading