{
  "openapi": "3.1.0",
  "info": {
    "title": "Kyle Berglund — Contact Form API",
    "summary": "The only server-side code on www.kyleberglund.com: the Cloudflare Turnstile gate in front of every lead form, and its readiness probe.",
    "description": "This site is otherwise a static build served straight off Cloudflare's asset edge. One Worker runs, for the `/api/*` prefix only, and it exposes the two operations below.\n\n`POST /api/submit` is **not a third-party API**. It accepts a submission only when the body carries a single-use Cloudflare Turnstile token that the widget on this site's own pages minted, and it rejects any request whose `Origin` is not this origin. No `Access-Control-Allow-Origin` header is ever sent, so a browser on another site cannot read a response from it either. A client that is not one of this site's own forms will receive `403` every time. It is described here because RFC 9727 asks a publisher to catalogue the APIs it actually runs, and because describing the gate is more useful to an automated client than leaving it to be discovered by trying.\n\n`GET /api/health` is anonymous, unmetered and safe to poll. It reports whether the Worker is *configured* — presence booleans only, never a secret's value — and never calls Turnstile siteverify unless asked to with `?verify=1`.",
    "version": "1.0.0",
    "termsOfService": "https://www.kyleberglund.com/terms-of-use",
    "contact": {
      "name": "Kyle Berglund",
      "email": "contact@kyleberglund.com",
      "url": "https://www.kyleberglund.com/contact"
    }
  },
  "servers": [
    {
      "url": "https://www.kyleberglund.com",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "description": "The contact form this endpoint serves",
    "url": "https://www.kyleberglund.com/contact"
  },
  "tags": [
    { "name": "forms", "description": "Lead form submission, behind the Turnstile gate" },
    { "name": "status", "description": "Readiness of the form pipeline" }
  ],
  "paths": {
    "/api/submit": {
      "post": {
        "tags": ["forms"],
        "summary": "Submit a lead form",
        "description": "Verifies the Cloudflare Turnstile token at siteverify and, only on success, composes the submission into an email and sends it to the site owner. Every rejection answers with the same opaque body; the reason is in the Worker's logs, not in the response. The request body is capped at 64 KiB.",
        "operationId": "submitForm",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/Submission" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verified and delivered",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "ok": { "type": "boolean", "const": true } },
                  "required": ["ok"]
                }
              }
            }
          },
          "400": {
            "description": "The body was not a JSON object (`error: \"bad_request\"`)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "403": {
            "description": "No token, a token siteverify would not accept (including a replayed one), or a cross-origin `Origin` header. Deliberately opaque: the body is `{\"ok\": false}` with no reason.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "ok": { "type": "boolean", "const": false } },
                  "required": ["ok"]
                }
              }
            }
          },
          "405": {
            "description": "Any method other than POST (`error: \"method_not_allowed\"`)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "413": {
            "description": "Body over 64 KiB (`error: \"payload_too_large\"`)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "500": {
            "description": "The Worker is not configured to deliver — a missing Turnstile secret, a missing email binding or address, or a sending domain that has not been onboarded (`error: \"misconfigured\"`). Retrying does not help; see `GET /api/health`.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "502": {
            "description": "Verified, but delivery failed downstream (`error: \"delivery_failed\"`). A retry with a fresh token may succeed.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          }
        }
      }
    },
    "/api/health": {
      "get": {
        "tags": ["status"],
        "summary": "Readiness probe for the form pipeline",
        "description": "Reports presence, never values. Answers on a Worker too misconfigured to accept a submission, which is exactly when it is worth reading. Responses are `Cache-Control: no-store`.",
        "operationId": "getFormHealth",
        "parameters": [
          {
            "name": "verify",
            "in": "query",
            "required": false,
            "description": "Set to `1` to additionally ask Cloudflare whether the configured Turnstile secret is accepted, returned as `turnstileSecretValid`. Opt-in because it costs an outbound request.",
            "schema": { "type": "string", "enum": ["1"] }
          }
        ],
        "responses": {
          "200": {
            "description": "Configured and able to accept a submission",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Health" } }
            }
          },
          "405": {
            "description": "Any method other than GET or HEAD (`error: \"method_not_allowed\"`)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "503": {
            "description": "Something the pipeline needs is missing or rejected. The same body as `200`, with `ok: false` and the offending field(s) reported false.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Health" } }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Submission": {
        "type": "object",
        "description": "The form's fields, plus the Turnstile token. Keys beyond those below are the fields of whichever form was submitted; each becomes a labelled line in the email. Keys starting with `_`, and `access_key`, are dropped before the email is composed.",
        "properties": {
          "cf-turnstile-response": {
            "type": "string",
            "description": "Single-use Cloudflare Turnstile token."
          },
          "formName": {
            "type": "string",
            "description": "Which form this came from. Used as the lead's label; defaults to `website` when absent.",
            "examples": ["contact", "home-valuation", "custom-home-search"]
          },
          "name": { "type": "string", "description": "The visitor's name, if the form collects one." },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Used as the email's `Reply-To` when it parses, and omitted from the header when it does not."
          }
        },
        "required": ["cf-turnstile-response"],
        "additionalProperties": true
      },
      "Health": {
        "type": "object",
        "description": "Presence booleans. No value of any secret is ever returned.",
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "True only when every field below is true (and, with `?verify=1`, the secret was accepted)."
          },
          "turnstileSecret": { "type": "boolean", "description": "The Turnstile secret key is visible to the Worker at runtime." },
          "emailBinding": { "type": "boolean", "description": "The email send binding is present." },
          "sender": { "type": "boolean", "description": "An envelope sender address is configured." },
          "recipient": { "type": "boolean", "description": "A destination address is configured." },
          "turnstileSecretValid": {
            "type": "boolean",
            "description": "Only present with `?verify=1`: whether Cloudflare accepted the configured secret. A secret can be present and still be the wrong one."
          },
          "misnamedSecrets": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Only present when set: names that hold a value nothing reads, each with the name it should have had."
          },
          "obsoleteSecrets": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Only present when set: names left over from a previous design, so a stale value is not mistaken for the cause of a failure it can no longer produce."
          },
          "hint": {
            "type": "string",
            "description": "Only present when nothing at all reached the Worker's runtime environment, naming the likeliest cause."
          }
        },
        "required": ["ok", "turnstileSecret", "emailBinding", "sender", "recipient"]
      },
      "Error": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean", "const": false },
          "error": {
            "type": "string",
            "enum": [
              "bad_request",
              "method_not_allowed",
              "payload_too_large",
              "misconfigured",
              "delivery_failed"
            ]
          }
        },
        "required": ["ok"]
      }
    }
  }
}
