openapi: 3.1.0
info:
  title: FormHandle API
  version: 1.0.0
  description: |
    FormHandle is a developer-first, API-first service that turns any HTML form
    into an email-delivering endpoint. Point your `<form action>` at FormHandle
    and submissions land in your inbox — no backend required.

    ## How it works

    1. Call `POST /setup` with your email and domain.
    2. Verify your email by clicking the link in your inbox.
    3. Set your form's `action` to the returned handler URL.

    ## Free trial

    Your first 3 submissions are delivered as full emails for free after email
    verification. From submission #4 onward, emails are queued until you activate
    a paid plan.

    ## Ads in responses

    Every JSON response includes promotional fields (`_ad1` through `_ad5`).
    These are safe to ignore.

    ## Error shape

    All JSON error responses follow a consistent shape with `error`, `_docs`
    (link to documentation), and `_tip` (actionable guidance on what went wrong).
  contact:
    name: FormHandle
    url: https://formhandle.dev
  license:
    name: Proprietary
    url: https://formhandle.dev
security: []
servers:
  - url: https://api.formhandle.dev
    description: Production

tags:
  - name: Setup
    description: Create and configure form endpoints
  - name: Submissions
    description: Submit form data and CORS preflight
  - name: Verification
    description: Email verification
  - name: Billing
    description: Stripe webhook and cancellation
  - name: Utilities
    description: Health check, embeddable script, welcome page

paths:
  /:
    get:
      operationId: healthCheck
      tags: [Utilities]
      summary: Health check
      description: Returns service status. Use this to confirm the API is reachable.
      responses:
        "200":
          description: Service is healthy
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/HealthResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                service: FormHandle
                status: ok
                _docs: https://formhandle.dev
                _tip: POST /setup to create a new form endpoint.

  /setup:
    post:
      operationId: createEndpoint
      tags: [Setup]
      summary: Create a new form endpoint
      description: |
        Validates the email and domain, generates a handler ID, inserts a new
        customer as `pending_verification`, and sends a verification email.

        **Rate limit:** 5 requests per IP per hour (shared with `/setup/resend`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SetupRequest"
      responses:
        "200":
          description: Endpoint created, verification email sent
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SetupResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                handler_id: my-form
                handler_url: https://api.formhandle.dev/submit/my-form
                status: pending_verification
                message: Check your email to verify your address.
                _docs: https://formhandle.dev
                _tip: Verification email sent. User must click the link before the endpoint accepts submissions.
        "400":
          description: Invalid request (bad JSON, missing/invalid email, missing/invalid domain, or invalid handler_id)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              examples:
                invalidJson:
                  summary: Invalid JSON body
                  value:
                    error: Invalid JSON body
                    _docs: https://formhandle.dev
                    _tip: "Send a JSON body with Content-Type: application/json."
                invalidEmail:
                  summary: Invalid email
                  value:
                    error: Valid email is required
                    _docs: https://formhandle.dev
                    _tip: "Provide a valid email in the 'email' field."
                invalidDomain:
                  summary: Invalid domain
                  value:
                    error: 'Valid domain is required (e.g. "acme.com", no protocol)'
                    _docs: https://formhandle.dev
                    _tip: "Provide a bare domain like 'acme.com', no protocol or path."
                invalidHandlerId:
                  summary: Invalid handler_id format
                  value:
                    error: Invalid handler_id. Use 3-32 lowercase alphanumeric characters and hyphens, must start/end with alphanumeric.
                    _docs: https://formhandle.dev
                    _tip: handler_id must be 3-32 chars, lowercase alphanumeric and hyphens, start/end with alphanumeric.
        "409":
          description: Conflict (email+domain already exists, or handler_id taken)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              examples:
                duplicateEndpoint:
                  summary: Email+domain pair already exists
                  value:
                    error: An endpoint already exists for this email and domain
                    _docs: https://formhandle.dev
                    _tip: This email+domain pair already has an endpoint.
                handlerIdTaken:
                  summary: handler_id already taken
                  value:
                    error: This handler_id is already taken
                    _docs: https://formhandle.dev
                    _tip: Choose a different handler_id.
        "429":
          $ref: "#/components/responses/RateLimited"

  /setup/resend:
    post:
      operationId: resendVerification
      tags: [Setup]
      summary: Resend verification email
      description: |
        Generates a fresh verification token and resends the verification email
        for a `pending_verification` endpoint. The previous verification link
        becomes invalid.

        **Rate limit:** 5 requests per IP per hour (shared with `POST /setup`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResendRequest"
      responses:
        "200":
          description: Verification email resent
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ResendResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                handler_id: my-form
                status: pending_verification
                message: Verification email resent. Check your inbox.
                _docs: https://formhandle.dev
                _tip: A new verification email has been sent. The previous verification link is no longer valid.
        "400":
          description: Invalid request (bad JSON or missing handler_id)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/AdsExtension"
        "404":
          description: No pending verification found for this handler_id
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                error: No pending verification found for this handler_id
                _docs: https://formhandle.dev
                _tip: This endpoint either does not exist or has already been verified.
        "429":
          $ref: "#/components/responses/RateLimited"

  /verify/{token}:
    get:
      operationId: verifyEmail
      tags: [Verification]
      summary: Verify email address
      description: |
        Email verification link. Clicking it marks the email as verified and
        transitions the endpoint from `pending_verification` to `queuing`.
        Returns an HTML confirmation page.

        This endpoint is typically opened by a human clicking a link in their
        email — you don't normally call it programmatically.
      parameters:
        - $ref: "#/components/parameters/VerificationToken"
      responses:
        "200":
          description: Email verified successfully (HTML page)
          content:
            text/html:
              schema:
                type: string
        "404":
          description: Invalid or already-used verification link (HTML page)
          content:
            text/html:
              schema:
                type: string

  /submit/{customer_id}:
    options:
      operationId: submitPreflight
      tags: [Submissions]
      summary: CORS preflight for form submission
      description: |
        Handles the browser's CORS preflight request. Returns appropriate
        `Access-Control-Allow-*` headers for the registered domain.
      parameters:
        - $ref: "#/components/parameters/CustomerId"
      responses:
        "204":
          description: Preflight accepted
          headers:
            Access-Control-Allow-Origin:
              schema:
                type: string
              description: The matched origin
            Access-Control-Allow-Methods:
              schema:
                type: string
                example: "POST, OPTIONS"
            Access-Control-Allow-Headers:
              schema:
                type: string
                example: Content-Type
            Access-Control-Max-Age:
              schema:
                type: string
                example: "86400"
        "404":
          description: Unknown endpoint

    post:
      operationId: submitForm
      tags: [Submissions]
      summary: Submit form data
      description: |
        Accepts form submissions as JSON or URL-encoded data. The `Origin` or
        `Referer` header must match the domain registered for this endpoint.
        `localhost` is always allowed for local testing.

        **Behaviour by endpoint status:**
        - `live` — email sent immediately, metered usage reported
        - `queuing` (first 3 submissions) — email sent immediately (free trial)
        - `queuing` (after 3) / `suspended` — submission queued, teaser email sent at thresholds
        - `pending_verification` — returns 403
        - Unknown endpoint ID — returns `{ "ok": true }` (does not leak valid IDs)

        **Spam protection:** The embeddable snippet injects `_fh_hp` (honeypot) and
        `_fh_ts` (timestamp) fields. These are checked server-side and stripped
        before storage. Honeypot-filled or timestamp-invalid submissions are rejected.

        **Rate limit:** 5 submissions per IP per endpoint per hour.
      parameters:
        - $ref: "#/components/parameters/CustomerId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: Arbitrary form field key-value pairs
              example:
                name: Jane Doe
                email: jane@example.com
                message: Hello from my website!
          application/x-www-form-urlencoded:
            schema:
              type: object
              additionalProperties: true
              description: URL-encoded form fields
      responses:
        "200":
          description: Submission accepted (or unknown endpoint ID)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SubmitResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                ok: true
                _docs: https://formhandle.dev
          headers:
            Access-Control-Allow-Origin:
              schema:
                type: string
              description: The matched origin
        "400":
          description: Could not parse request body
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                error: Could not parse request body
                _docs: https://formhandle.dev
                _tip: Send JSON or URL-encoded form data.
        "403":
          description: Origin not allowed or endpoint not yet verified
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              examples:
                originNotAllowed:
                  summary: Origin mismatch
                  value:
                    error: Origin not allowed
                    _docs: https://formhandle.dev
                    _tip: The Origin header must match the domain registered for this endpoint.
                notVerified:
                  summary: Endpoint not verified
                  value:
                    error: Endpoint not yet verified
                    _docs: https://formhandle.dev
                    _tip: The owner must click the verification link in their email before this endpoint accepts submissions.
        "422":
          description: Spam detected (honeypot or timestamp check failed)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                error: Submission rejected
                _docs: https://formhandle.dev
                _tip: Spam detected.
        "429":
          $ref: "#/components/responses/RateLimited"

  /webhook/stripe:
    post:
      operationId: stripeWebhook
      tags: [Billing]
      summary: Stripe webhook
      description: |
        Receives Stripe webhook events. Signature is verified using
        HMAC-SHA256 via `crypto.subtle` (no Stripe SDK).

        **Handled event types:**
        - `checkout.session.completed` — activates the endpoint, flushes queued submissions
        - `customer.subscription.deleted` / `paused` — suspends the endpoint
        - `customer.subscription.resumed` / `invoice.paid` — reactivates the endpoint

        This endpoint is called by Stripe, not by API consumers.
      parameters:
        - name: stripe-signature
          in: header
          required: true
          schema:
            type: string
          description: Stripe webhook signature header
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Raw Stripe event payload
      responses:
        "200":
          description: Webhook processed
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                received: true
        "400":
          description: Missing or invalid signature, or invalid JSON
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              examples:
                missingSignature:
                  summary: Missing stripe-signature header
                  value:
                    error: Missing stripe-signature header
                invalidSignature:
                  summary: Invalid signature
                  value:
                    error: Invalid signature
                invalidJson:
                  summary: Invalid JSON
                  value:
                    error: Invalid JSON

  /cancel/{customer_id}:
    post:
      operationId: initiateCancellation
      tags: [Billing]
      summary: Initiate subscription cancellation
      description: |
        Generates a cancellation token and sends a confirmation email to the
        endpoint owner. The human must click the link in the email to confirm.
        The subscription cancels at the end of the current billing period.
      parameters:
        - $ref: "#/components/parameters/CustomerId"
      responses:
        "200":
          description: Cancellation email sent
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CancelResponse"
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                ok: true
                message: Check your email to confirm cancellation.
        "404":
          description: No active subscription found
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    properties:
                      ok:
                        type: boolean
                        example: false
                      error:
                        type: string
                  - $ref: "#/components/schemas/AdsExtension"
              example:
                ok: false
                error: No active subscription found.

  /cancel/confirm/{token}:
    get:
      operationId: showCancellationPage
      tags: [Billing]
      summary: Show cancellation confirmation page
      description: |
        Displays an HTML page where the user can confirm their subscription
        cancellation by clicking a button. Opened by the human from their email.
      parameters:
        - $ref: "#/components/parameters/CancellationToken"
      responses:
        "200":
          description: Cancellation confirmation page (HTML)
          content:
            text/html:
              schema:
                type: string
        "404":
          description: Invalid or already-used cancellation link (HTML)
          content:
            text/html:
              schema:
                type: string

    post:
      operationId: executeCancellation
      tags: [Billing]
      summary: Execute subscription cancellation
      description: |
        Confirms and executes the cancellation. Sets the Stripe subscription to
        cancel at the end of the current billing period. Returns an HTML
        confirmation page showing the end date.
      parameters:
        - $ref: "#/components/parameters/CancellationToken"
      responses:
        "200":
          description: Subscription cancelled successfully (HTML)
          content:
            text/html:
              schema:
                type: string
        "400":
          description: No subscription found for this customer (HTML)
          content:
            text/html:
              schema:
                type: string
        "404":
          description: Invalid or already-used cancellation link (HTML)
          content:
            text/html:
              schema:
                type: string
        "500":
          description: Could not cancel subscription at this time (HTML)
          content:
            text/html:
              schema:
                type: string

  /s/{customer_id}.js:
    get:
      operationId: getEmbeddableScript
      tags: [Utilities]
      summary: Embeddable JavaScript snippet
      description: |
        Returns a self-contained JavaScript snippet that, when included on a
        page, automatically enhances all `<form data-formhandle>` elements with:

        - A hidden honeypot field (`_fh_hp`) for spam protection
        - A hidden timestamp field (`_fh_ts`) for replay protection
        - AJAX submission with success/error messages

        The snippet is cached for 1 hour (`Cache-Control: public, max-age=3600`).
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
          description: The handler ID for the endpoint
      responses:
        "200":
          description: JavaScript snippet
          content:
            application/javascript:
              schema:
                type: string
          headers:
            Cache-Control:
              schema:
                type: string
                example: "public, max-age=3600"
        "404":
          description: Unknown endpoint (returns a JavaScript comment)
          content:
            application/javascript:
              schema:
                type: string
              example: "// FormHandle: unknown endpoint"

  /welcome:
    get:
      operationId: welcomePage
      tags: [Utilities]
      summary: Post-payment welcome page
      description: |
        Redirect target after a successful Stripe checkout. Displays the
        customer's plan type and provides the HTML snippet to embed on their site.
      parameters:
        - name: customer_id
          in: query
          required: false
          schema:
            type: string
          description: The handler ID for the endpoint
      responses:
        "200":
          description: Welcome page (HTML)
          content:
            text/html:
              schema:
                type: string

components:
  parameters:
    CustomerId:
      name: customer_id
      in: path
      required: true
      schema:
        type: string
        pattern: "^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$"
      description: The handler ID for the endpoint (3-32 chars, lowercase alphanumeric and hyphens)

    VerificationToken:
      name: token
      in: path
      required: true
      schema:
        type: string
      description: Email verification token (from the verification email link)

    CancellationToken:
      name: token
      in: path
      required: true
      schema:
        type: string
      description: Cancellation confirmation token (from the cancellation email link)

  schemas:
    SetupRequest:
      type: object
      required: [email, domain]
      properties:
        email:
          type: string
          format: email
          description: Email address to receive form submissions
          example: you@example.com
        domain:
          type: string
          description: "Bare domain (e.g. `acme.com`), no protocol or path"
          example: example.com
        handler_id:
          type: string
          pattern: "^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$"
          description: "Custom endpoint ID (3-32 chars, lowercase alphanumeric + hyphens, must start/end with alphanumeric). Auto-generated if omitted."
          example: contact-form

    SetupResponse:
      type: object
      properties:
        handler_id:
          type: string
          description: The endpoint ID (auto-generated or custom)
          example: contact-form
        handler_url:
          type: string
          format: uri
          description: The full URL to use as your form's action
          example: https://api.formhandle.dev/submit/contact-form
        status:
          type: string
          enum: [pending_verification]
          description: Initial status — always `pending_verification`
        message:
          type: string
          example: Check your email to verify your address.
        _docs:
          type: string
          format: uri
          example: https://formhandle.dev
        _tip:
          type: string
          example: Verification email sent. User must click the link before the endpoint accepts submissions.

    ResendRequest:
      type: object
      required: [handler_id]
      properties:
        handler_id:
          type: string
          description: The handler ID received from `POST /setup`
          example: contact-form

    ResendResponse:
      type: object
      properties:
        handler_id:
          type: string
          example: contact-form
        status:
          type: string
          enum: [pending_verification]
        message:
          type: string
          example: Verification email resent. Check your inbox.
        _docs:
          type: string
          format: uri
          example: https://formhandle.dev
        _tip:
          type: string
          example: A new verification email has been sent. The previous verification link is no longer valid.

    SubmitResponse:
      type: object
      properties:
        ok:
          type: boolean
          example: true
        _docs:
          type: string
          format: uri
          example: https://formhandle.dev

    CancelResponse:
      type: object
      properties:
        ok:
          type: boolean
          example: true
        message:
          type: string
          example: Check your email to confirm cancellation.

    WebhookResponse:
      type: object
      properties:
        received:
          type: boolean
          example: true

    HealthResponse:
      type: object
      properties:
        service:
          type: string
          example: FormHandle
        status:
          type: string
          example: ok
        _docs:
          type: string
          format: uri
          example: https://formhandle.dev
        _tip:
          type: string
          example: POST /setup to create a new form endpoint.

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message
        _docs:
          type: string
          format: uri
          description: Link to the documentation
          example: https://formhandle.dev
        _tip:
          type: string
          description: Actionable guidance on what went wrong and how to fix it

    AdsExtension:
      type: object
      description: Promotional fields appended to every JSON response. Safe to ignore.
      properties:
        _ad1:
          type: string
        _ad2:
          type: string
        _ad3:
          type: string
        _ad4:
          type: string
        _ad5:
          type: string

  responses:
    RateLimited:
      description: Too many requests — rate limit exceeded
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ErrorResponse"
              - $ref: "#/components/schemas/AdsExtension"
          example:
            error: Too many requests. Try again later.
            _docs: https://formhandle.dev
            _tip: Rate limited. Wait a few minutes before retrying.
