openapi: 3.1.0
info:
  title: The Decksmith Public API
  description: |
    Consultation lead intake for The Decksmith — Sydney Eastern Suburbs deck and pergola builder (NSW licence 272840C).
    
    Browser forms continue to use application/x-www-form-urlencoded with 303 redirects. Agents should prefer Accept: application/json and/or Content-Type: application/json for JSON request/response shapes documented here. No secrets are exposed by this API.
    
    ## Versioning
    Current API version is **1**, exposed primarily at `/api/v1/lead`. `/api/lead` is a stable alias for the same v1 handler (not a separate implementation). Breaking changes will introduce `/api/v2/...`. When a path is retired it will send `Deprecation` and `Sunset` headers (none retired yet). Successful and error responses include `API-Version: 1`.
    
    ## Rate limiting
    Soft per-IP limit: **30 POSTs per 60 seconds** (in-isolate best-effort). Responses include `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Policy: "30;w=60"`. When exceeded: HTTP 429 JSON for JSON clients (with `Retry-After`), or 303 to `/contact/` for browser form posts.
  version: 1.1.0
  contact:
    name: Greg — The Decksmith
    email: "greg@thedecksmith.com.au"
    url: "https://www.thedecksmith.com.au/contact/"
  x-deprecation-policy: Versions live in the URL path (/api/v1, /api/v2, …). Breaking changes ship as a new major path version. Retired paths advertise Deprecation and Sunset response headers before removal. No paths are retired as of this document. /api/lead remains a non-deprecated stable alias of v1.
servers:
  - url: "https://www.thedecksmith.com.au"
    description: Production
tags:
  - name: Leads
    description: Consultation lead intake and health
paths:
  "/api/v1/lead":
    get:
      operationId: getLeadHealthV1
      tags:
        - Leads
      summary: Lead endpoint health check (v1)
      description: "Returns JSON health status for the Consultation lead intake endpoint. Suitable for uptime monitors and agents verifying the API is reachable. Includes RateLimit-* and API-Version headers (GET does not consume the POST quota)."
      responses:
        "200":
          $ref: "#/components/responses/LeadHealthOk"
        "405":
          $ref: "#/components/responses/LeadMethodNotAllowed"
    post:
      operationId: submitConsultationLeadV1
      tags:
        - Leads
      summary: Submit a Consultation lead (v1)
      description: "Accepts a Consultation request from homeowners (or agents acting for them). Supports application/x-www-form-urlencoded (browser forms) and application/json (agents). Required fields: name_1, email_1. Optional: phone_1, text_1 (postcode), textarea_1 (message), botcheck (honeypot — leave empty). When the client wants JSON (Accept includes application/json, or Content-Type is application/json), success is 200 JSON and validation/parse failures are 400 JSON with `{ error: { code, message, resolution } }`. Otherwise browser clients receive 303 redirects to /thanks/ (success) or /contact/ (failure). Soft rate limit: 30 POSTs / 60s per IP → 429 JSON or 303 /contact/ with Retry-After."
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/LeadRequest"
          application/json:
            schema:
              $ref: "#/components/schemas/LeadRequest"
      responses:
        "200":
          $ref: "#/components/responses/LeadAcceptedJson"
        "303":
          $ref: "#/components/responses/LeadRedirect"
        "400":
          $ref: "#/components/responses/LeadBadRequest"
        "429":
          $ref: "#/components/responses/LeadRateLimited"
        "405":
          $ref: "#/components/responses/LeadMethodNotAllowed"
    options:
      operationId: optionsLeadV1
      tags:
        - Leads
      summary: CORS preflight for lead endpoint
      description: Handles browser CORS preflight. Returns 204 with Allow-Methods GET, POST, OPTIONS.
      responses:
        "204":
          $ref: "#/components/responses/CorsPreflight"
    summary: Consultation lead intake (primary v1 path)
    description: Primary versioned path. Identical behaviour to /api/lead.
  "/api/lead":
    get:
      operationId: getLeadHealth
      tags:
        - Leads
      summary: Lead endpoint health check (stable alias)
      description: "Returns JSON health status for the Consultation lead intake endpoint. Suitable for uptime monitors and agents verifying the API is reachable. Includes RateLimit-* and API-Version headers (GET does not consume the POST quota)."
      responses:
        "200":
          $ref: "#/components/responses/LeadHealthOk"
        "405":
          $ref: "#/components/responses/LeadMethodNotAllowed"
    post:
      operationId: submitConsultationLead
      tags:
        - Leads
      summary: Submit a Consultation lead (stable alias)
      description: "Accepts a Consultation request from homeowners (or agents acting for them). Supports application/x-www-form-urlencoded (browser forms) and application/json (agents). Required fields: name_1, email_1. Optional: phone_1, text_1 (postcode), textarea_1 (message), botcheck (honeypot — leave empty). When the client wants JSON (Accept includes application/json, or Content-Type is application/json), success is 200 JSON and validation/parse failures are 400 JSON with `{ error: { code, message, resolution } }`. Otherwise browser clients receive 303 redirects to /thanks/ (success) or /contact/ (failure). Soft rate limit: 30 POSTs / 60s per IP → 429 JSON or 303 /contact/ with Retry-After."
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/LeadRequest"
          application/json:
            schema:
              $ref: "#/components/schemas/LeadRequest"
      responses:
        "200":
          $ref: "#/components/responses/LeadAcceptedJson"
        "303":
          $ref: "#/components/responses/LeadRedirect"
        "400":
          $ref: "#/components/responses/LeadBadRequest"
        "429":
          $ref: "#/components/responses/LeadRateLimited"
        "405":
          $ref: "#/components/responses/LeadMethodNotAllowed"
    options:
      operationId: optionsLead
      tags:
        - Leads
      summary: CORS preflight for lead endpoint
      description: Handles browser CORS preflight. Returns 204 with Allow-Methods GET, POST, OPTIONS.
      responses:
        "204":
          $ref: "#/components/responses/CorsPreflight"
    summary: Consultation lead intake (stable alias of v1)
    description: "Stable alias for `/api/v1/lead`. Same shared handler — not a duplicate implementation. Prefer `/api/v1/lead` for new agent integrations; this alias remains supported."
    deprecated: false
    x-alias-of: /api/v1/lead
components:
  schemas:
    LeadRequest:
      type: object
      required:
        - name_1
        - email_1
      properties:
        name_1:
          type: string
          description: Full name of the person requesting a Consultation
          minLength: 1
          examples:
            - Alex Example
        email_1:
          type: string
          format: email
          description: Contact email
          examples:
            - "alex@example.com"
        phone_1:
          type: string
          description: Contact phone (optional)
          examples:
            - 0468 486 290
        text_1:
          type: string
          description: Postcode (optional)
          examples:
            - 2026
        textarea_1:
          type: string
          description: Project message / notes (optional)
        botcheck:
          type: string
          description: Honeypot field for bots — humans and legitimate agents must leave this empty or omit it
          default: ""
      additionalProperties: true
    LeadHealth:
      type: object
      required:
        - status
      properties:
        status:
          type: string
          const: ok
          description: Health indicator
      additionalProperties: false
    LeadSuccess:
      type: object
      required:
        - ok
        - message
      properties:
        ok:
          type: boolean
          const: true
        message:
          type: string
          examples:
            - Consultation request received
      additionalProperties: false
    ApiError:
      type: object
      required:
        - code
        - message
        - resolution
      properties:
        code:
          type: string
          description: Machine-readable error code
          enum:
            - VALIDATION_ERROR
            - PARSE_ERROR
            - RATE_LIMITED
            - METHOD_NOT_ALLOWED
        message:
          type: string
          description: Human-readable error summary
        resolution:
          type: string
          description: How to fix the request
        details:
          description: Optional structured details
          nullable: true
      additionalProperties: false
    ApiErrorEnvelope:
      type: object
      required:
        - error
      properties:
        error:
          $ref: "#/components/schemas/ApiError"
      additionalProperties: false
  headers:
    RateLimitLimit:
      description: Soft request limit for this client IP in the current window
      schema:
        type: string
        examples:
          - 30
    RateLimitRemaining:
      description: Remaining POSTs allowed in the current window (best-effort)
      schema:
        type: string
        examples:
          - 29
    RateLimitPolicy:
      description: "RFC-ish policy token: limit;w=windowSeconds"
      schema:
        type: string
        examples:
          - 30;w=60
    RetryAfter:
      description: Seconds to wait before retrying (present on 429)
      schema:
        type: string
        examples:
          - 60
    ApiVersion:
      description: API major version served by this handler
      schema:
        type: string
        const: 1
    Allow:
      description: Allowed HTTP methods
      schema:
        type: string
        examples:
          - GET, POST, OPTIONS
  responses:
    LeadHealthOk:
      description: Endpoint healthy
      headers:
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Policy:
          $ref: "#/components/headers/RateLimitPolicy"
        API-Version:
          $ref: "#/components/headers/ApiVersion"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/LeadHealth"
          example:
            status: ok
    LeadAcceptedJson:
      description: Lead accepted (JSON clients)
      headers:
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Policy:
          $ref: "#/components/headers/RateLimitPolicy"
        API-Version:
          $ref: "#/components/headers/ApiVersion"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/LeadSuccess"
          example:
            ok: true
            message: Consultation request received
    LeadRedirect:
      description: "Browser form flow redirect. Success Location: /thanks/. Failure or rate-limit Location: /contact/."
      headers:
        Location:
          description: Redirect target
          schema:
            type: string
            format: uri
            examples:
              - /thanks/
              - /contact/
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Policy:
          $ref: "#/components/headers/RateLimitPolicy"
        API-Version:
          $ref: "#/components/headers/ApiVersion"
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
    LeadBadRequest:
      description: Validation or parse error (JSON clients)
      headers:
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Policy:
          $ref: "#/components/headers/RateLimitPolicy"
        API-Version:
          $ref: "#/components/headers/ApiVersion"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorEnvelope"
          examples:
            validation:
              summary: Missing required fields
              value:
                error:
                  code: VALIDATION_ERROR
                  message: name_1 and a valid email_1 are required
                  resolution: Provide name_1 and a valid email_1. See /openapi.json
            parse:
              summary: Unparseable body
              value:
                error:
                  code: PARSE_ERROR
                  message: Could not parse request body
                  resolution: Send application/json with an object body, or application/x-www-form-urlencoded. See /openapi.json
                  details:
                    reason: JSON body must be an object
    LeadRateLimited:
      description: Soft per-IP rate limit exceeded (JSON clients)
      headers:
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Policy:
          $ref: "#/components/headers/RateLimitPolicy"
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        API-Version:
          $ref: "#/components/headers/ApiVersion"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorEnvelope"
          example:
            error:
              code: RATE_LIMITED
              message: Too many Consultation requests from this IP
              resolution: "Wait for Retry-After seconds, then retry. Soft limit: 30 POSTs per 60 seconds per IP."
              details:
                limit: 30
                windowSeconds: 60
    LeadMethodNotAllowed:
      description: HTTP method not allowed
      headers:
        Allow:
          $ref: "#/components/headers/Allow"
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Policy:
          $ref: "#/components/headers/RateLimitPolicy"
        API-Version:
          $ref: "#/components/headers/ApiVersion"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiErrorEnvelope"
          example:
            error:
              code: METHOD_NOT_ALLOWED
              message: Method not allowed
              resolution: Use GET (health), POST (submit lead), or OPTIONS (CORS preflight). See /openapi.json
              details:
                allow:
                  - GET
                  - POST
                  - OPTIONS
    CorsPreflight:
      description: CORS preflight OK
      headers:
        Access-Control-Allow-Origin:
          schema:
            type: string
            examples:
              - "*"
        Access-Control-Allow-Methods:
          schema:
            type: string
            examples:
              - GET, POST, OPTIONS
        Access-Control-Allow-Headers:
          schema:
            type: string
            examples:
              - Content-Type, Accept
        Access-Control-Max-Age:
          schema:
            type: string
            examples:
              - 86400
        RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        RateLimit-Policy:
          $ref: "#/components/headers/RateLimitPolicy"
        API-Version:
          $ref: "#/components/headers/ApiVersion"
