{
  "openapi": "3.1.0",
  "info": {
    "title": "AgenticWebScore API",
    "version": "0.7",
    "description": "The 4 API endpoints of AgenticWebScore that answer JSON. None takes a credential from an agent. The limits live in the memory of the serving process, per client unless stated, and reset when it restarts. A refused request gets 429 with `Retry-After`.",
    "contact": {
      "email": "hello@agenticwebscore.com"
    }
  },
  "servers": [
    {
      "url": "https://agenticwebscore.com"
    }
  ],
  "externalDocs": {
    "url": "https://agenticwebscore.com/methodology",
    "description": "What a scan record means: the scoring contract"
  },
  "paths": {
    "/api/scan": {
      "summary": "Scan a public site, or return its stored record",
      "description": "It answers inline with the scan record as JSON, once the record is ready. A stored record younger than 6 hours, measured with the same `profile`, is returned without a request to the site. `fresh` asks for a new scan. A running scan of the same origin with the same `profile` is joined, not repeated. Any other scan of that site gets 429 until it finishes. A site that cannot be measured returns 200 with `score: null` and `grade: \"NA\"`. A scan stops at 60 s, and a scan cut short is reported as not measured. New scans: 10 per client per minute and 3 running at once. A reused record or a joined scan does not count.",
      "get": {
        "operationId": "scanGet",
        "parameters": [
          {
            "name": "target",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 3,
              "description": "The site to scan: a host name, with a port when it is not the default, or an http(s) URL."
            }
          },
          {
            "name": "profile",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "ecommerce",
                "marketplace",
                "saas",
                "api",
                "publisher",
                "services",
                "institution",
                "general"
              ],
              "description": "Declare the site profile instead of letting the scanner detect it."
            }
          },
          {
            "name": "fresh",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1",
                "true"
              ]
            },
            "description": "Scan again instead of returning a stored record."
          }
        ],
        "responses": {
          "200": {
            "description": "The scan record, stored or new.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanRecord"
                }
              }
            }
          },
          "400": {
            "description": "The request names no usable target, or an unknown profile. `error` is `invalid_target`, `unknown_profile`, or `target_refused`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over the scan limit, or a scan of the same site is already running under another profile.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The scan failed on this side. `error` is `scan_failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "scanPost",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "target"
                ],
                "properties": {
                  "target": {
                    "type": "string",
                    "minLength": 3,
                    "description": "The site to scan: a host name, with a port when it is not the default, or an http(s) URL."
                  },
                  "profile": {
                    "type": "string",
                    "enum": [
                      "ecommerce",
                      "marketplace",
                      "saas",
                      "api",
                      "publisher",
                      "services",
                      "institution",
                      "general"
                    ],
                    "description": "Declare the site profile instead of letting the scanner detect it."
                  },
                  "fresh": {
                    "type": "boolean",
                    "description": "true scans again instead of returning a stored record. 1, \"1\" and \"true\" are read the same way."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The scan record, stored or new.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanRecord"
                }
              }
            }
          },
          "400": {
            "description": "The request names no usable target, or an unknown profile. `error` is `invalid_body`, `invalid_target`, `unknown_profile`, or `target_refused`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over the scan limit, or a scan of the same site is already running under another profile.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The scan failed on this side. `error` is `scan_failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/badge/claim": {
      "summary": "Start a directory listing",
      "description": "Returns a token and the snippet to publish on the site. Nothing is listed until `/api/badge/verify` finds the token there. A site with no stored record is scanned first, which spends a scan. Claims: 10 per client per minute. Any other method answers 405 with `Allow: POST` and a JSON body.",
      "post": {
        "operationId": "badgeClaim",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "target"
                ],
                "properties": {
                  "target": {
                    "type": "string",
                    "minLength": 3,
                    "description": "The site to scan: a host name, with a port when it is not the default, or an http(s) URL."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The token, the snippet and the measurement the claim refers to. `listed` is false.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "host",
                    "token",
                    "listed",
                    "snippet"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "The body is not JSON, or names no usable target. `error` is `invalid_body`, `invalid_target`, or `target_refused`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The site cannot be claimed. `error` is `not_claimable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over the claim limit, or over the scan limit when a scan was needed.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The claim failed on this side. `error` is `claim_failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/badge/verify": {
      "summary": "Confirm the snippet is published, and list the site",
      "description": "Fetches `/`, `/agenticwebscore`, and `/verify` on the claimed site and looks for the token as the content of the `agenticwebscore-verify` meta tag in the head. Found, the site is listed. Verifications: 10 per client per minute, and 3 per token in 10 minutes. Any other method answers 405 with `Allow: POST` and a JSON body.",
      "post": {
        "operationId": "badgeVerify",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The token was found. `listed` is true.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "host",
                    "listed"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "The body is not JSON, or carries no token. `error` is `invalid_body` or `missing_token`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The token was not found, or is unknown or expired. Nothing is listed. `error` is `not_verified`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Over the verification limit for this client or this token.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "No page of the site answered, so nothing changed (`changed` is false), or the verification failed on this side. `error` is `not_checked` or `verify_failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/health": {
      "summary": "Health of the serving process",
      "description": "Storage probe, rubric version, record counts and the failure analysis. 200 while the store is writable, 503 when it is not. The operator's bearer token adds detail; the public body needs none. 30 per client per minute.",
      "get": {
        "operationId": "health",
        "responses": {
          "200": {
            "description": "`status` is `ok` or `degraded`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status"
                  ]
                }
              }
            }
          },
          "429": {
            "description": "Over the health-check limit. `status` is `rate_limited`.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthRefusal"
                }
              }
            }
          },
          "503": {
            "description": "The store cannot be written: `status` is `degraded` and `statusReasons` holds `storage-unwritable`. `unhealthy` means the health check itself failed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status"
                  ]
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "headers": {
      "RetryAfter": {
        "description": "Seconds to wait before asking again.",
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error",
          "message"
        ],
        "properties": {
          "error": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "HealthRefusal": {
        "type": "object",
        "required": [
          "status",
          "message"
        ],
        "properties": {
          "status": {
            "const": "rate_limited"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "ScanRecord": {
        "type": "object",
        "description": "A scan record as https://agenticwebscore.com/methodology defines it. The fingerprint is checked at /verify/{fingerprint}.",
        "required": [
          "rubricVersion",
          "target",
          "host",
          "scannedAt",
          "score",
          "grade",
          "level",
          "fingerprint",
          "checks",
          "counts"
        ],
        "properties": {
          "rubricVersion": {
            "type": "string"
          },
          "target": {
            "type": "string"
          },
          "host": {
            "type": "string"
          },
          "scannedAt": {
            "type": "string",
            "format": "date-time"
          },
          "score": {
            "type": [
              "number",
              "null"
            ],
            "description": "null when the site could not be measured."
          },
          "grade": {
            "type": "string",
            "description": "\"NA\" when the site could not be measured."
          },
          "level": {
            "type": [
              "integer",
              "null"
            ]
          },
          "fingerprint": {
            "type": "string"
          },
          "checks": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "status",
                "points",
                "detail"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "pass",
                    "warn",
                    "fail",
                    "na"
                  ]
                },
                "points": {
                  "type": "number"
                },
                "detail": {
                  "type": "string"
                },
                "evidence": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          },
          "counts": {
            "type": "object"
          }
        }
      }
    }
  }
}