openapi: 3.1.0

# The Leadmend ingest API.
#
# Two endpoints, one credential, and a deliberately small surface. Everything
# here was written against app/api/ingest/[token]/route.ts and its verify
# sibling rather than from memory — the caps, the status codes and the error
# strings below are the ones the code actually returns.
#
# Served at https://leadmend.com/openapi.yaml and rendered for humans at
# https://leadmend.com/docs/api

info:
  title: Leadmend ingest API
  version: "1.0.0"
  summary: Submit a website or form enquiry to a Leadmend business and receive the reply that was sent to the enquirer.
  description: |
    Leadmend answers written enquiries. This API is how an enquiry reaches it
    from somewhere other than a Leadmend-built website — a WordPress form, a
    Zap, a custom front end, anything that can make an HTTP request.

    **What it does not do.** There is no endpoint here that reads leads back,
    and there will not be one on this credential. The token below is public by
    design and grants exactly one capability: submit an enquiry to one business.
    See the security note on `IngestToken`.

    **Latency.** A successful call performs an AI drafting step before it
    returns, so expect single-digit seconds rather than milliseconds. Callers
    that must not block a user should dispatch without waiting — the enquiry is
    recorded and answered either way.
  contact:
    name: Leadmend
    url: https://leadmend.com/contact
    email: hello@leadmend.com
  license:
    name: Proprietary
    url: https://leadmend.com/terms

servers:
  - url: https://leadmend.com
    description: Production. There is no sandbox; use a business you own.

tags:
  - name: Enquiries
    description: Submitting an enquiry and checking the credential that submits it.

paths:
  /api/ingest/{token}:
    post:
      tags: [Enquiries]
      operationId: submitEnquiry
      summary: Submit an enquiry
      description: |
        Records the enquiry against the business the token belongs to, drafts a
        reply in that business's voice, sends it to the enquirer when an email
        address is present, and alerts the owner.

        Accepts either `application/json` or `application/x-www-form-urlencoded`,
        so a plain HTML `<form>` can post here with no JavaScript at all.

        **At least one of `phone` or `message` is required.** An enquiry with
        neither has no way to reach the person and nothing to write a reply
        from, and is rejected with `need_phone_or_message`.

        **Duplicates are absorbed, not rejected.** The same phone/message from
        the same business inside a 10-minute window returns `200` with
        `duplicate: true` — a double-clicked submit button does not produce a
        second reply to the customer.

        **An AI outage does not cost the enquiry.** If drafting fails the lead
        is still saved, the owner is alerted that a human needs to answer it,
        and the enquirer receives a holding reply. The response is still `200`.
      parameters:
        - $ref: "#/components/parameters/Token"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Enquiry"
            examples:
              typical:
                summary: A website enquiry
                value:
                  name: Sarah Doucette
                  email: sarah@example.com
                  phone: "902-555-0142"
                  message: Hi, could I get a quote for a full bathroom renovation?
              messageOnly:
                summary: Minimum viable enquiry
                description: No phone, no name — a message alone is enough.
                value:
                  message: Do you cover the north end on weekends?
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/Enquiry"
      responses:
        "200":
          description: |
            Accepted. `reply` is the text that was sent to the enquirer, so a
            caller can display or log it. `duplicate` is present and true only
            when the enquiry matched a recent one.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnquiryAccepted"
              examples:
                answered:
                  value:
                    ok: true
                    reply: "Hi Sarah — thanks for getting in touch. I'm not at a desk right now, but I'd be glad to help with the bathroom renovation. Could you tell me roughly the size of the room?"
                    urgent: false
                duplicate:
                  value:
                    ok: true
                    duplicate: true
                    reply: "Looks like we already received this — it's in safe hands and you'll hear back shortly."
                    urgent: false
        "400":
          description: |
            `bad_request` — the body could not be parsed.
            `need_phone_or_message` — neither field was supplied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                needPhoneOrMessage:
                  value: { error: need_phone_or_message }
                badRequest:
                  value: { error: bad_request }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

    options:
      tags: [Enquiries]
      operationId: submitEnquiryPreflight
      summary: CORS preflight
      description: |
        Returns `204` with permissive CORS headers. This endpoint is called by
        customers' own websites on their own domains, so it must be
        cross-origin callable. `Access-Control-Allow-Origin: *` is correct here
        specifically because the token in the path is the only credential and
        no cookies are ever accepted — there is no CSRF surface to widen.
      parameters:
        - $ref: "#/components/parameters/Token"
      responses:
        "204":
          description: Preflight OK.
          headers:
            Access-Control-Allow-Origin:
              schema: { type: string, examples: ["*"] }
            Access-Control-Allow-Methods:
              schema: { type: string, examples: ["POST, OPTIONS"] }

  /api/ingest/{token}/verify:
    get:
      tags: [Enquiries]
      operationId: verifyToken
      summary: Check a token without creating anything
      description: |
        Answers "is this token good?" without writing an enquiry.

        This exists because integrations re-check credentials in the background.
        Without it the only way to test a token would be to POST a lead — which
        would bill an AI reply and email the owner about a customer who does not
        exist, every time.

        It confirms that a token resolves, which the POST endpoint deliberately
        avoids doing via its generic `404`. That is not extra exposure: anyone
        holding the token learns the same fact by posting an enquiry and reading
        `200` versus `404`. **Nothing about a lead is reachable here.**
      parameters:
        - $ref: "#/components/parameters/Token"
      responses:
        "200":
          description: The token resolves to an active business.
          content:
            application/json:
              schema:
                type: object
                required: [ok, business]
                properties:
                  ok:
                    type: boolean
                    const: true
                  business:
                    type: string
                    description: |
                      The business's display name, for labelling a connection in
                      the integrating app. Already printed on the website the
                      token came from.
                    examples: ["Northgate Plumbing"]
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

components:
  parameters:
    Token:
      name: token
      in: path
      required: true
      description: The business's ingest token. See the `IngestToken` scheme.
      schema:
        type: string
        minLength: 16
        maxLength: 128

  schemas:
    Enquiry:
      type: object
      description: |
        Every field is optional individually, but **at least one of `phone` or
        `message` must be present**. Values longer than the stated maximum are
        truncated rather than rejected.
      properties:
        name:
          type: string
          maxLength: 80
          description: The enquirer's name. Used to address the reply.
          examples: ["Sarah Doucette"]
        email:
          type: string
          maxLength: 200
          description: |
            Where the reply is sent. Without it the enquiry is still recorded
            and the owner still alerted, but the enquirer cannot be answered.
          examples: ["sarah@example.com"]
        phone:
          type: string
          maxLength: 40
          description: |
            Stored and shown with the lead. Leadmend does not call or send SMS —
            it answers written enquiries.
          examples: ["902-555-0142"]
        message:
          type: string
          maxLength: 1000
          description: |
            What the enquirer wrote. This is what the reply is drafted from, so
            send the real message rather than a subject line.
          examples: ["Hi, could I get a quote for a full bathroom renovation?"]

    EnquiryAccepted:
      type: object
      required: [ok, reply, urgent]
      properties:
        ok:
          type: boolean
          const: true
        reply:
          type: string
          description: The text sent to the enquirer.
        urgent:
          type: boolean
          description: |
            True when the enquiry was judged urgent — a leak, a safety risk,
            something that should not wait for a booking.
        duplicate:
          type: boolean
          description: |
            Present and true only when this matched a recent enquiry. No second
            reply was sent.

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [bad_request, need_phone_or_message, not_found, rate_limited]
        retryAfter:
          type: integer
          description: Seconds to wait. Present on some rate-limit responses.

  responses:
    NotFound:
      description: |
        The token did not resolve to an active business. Deliberately generic on
        the POST endpoint — it never confirms whether a token exists.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            notFound:
              value: { error: not_found }

    RateLimited:
      description: |
        Two independent limits produce this. A per-IP limiter guards the
        endpoint, and a durable per-business cap of **60 enquiries per hour**
        guards spend — every accepted enquiry costs an AI call. The per-business
        cap is counted from stored rows, so it cannot be dodged by spreading
        requests around.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            rateLimited:
              value: { error: rate_limited, retryAfter: 42 }

  securitySchemes:
    IngestToken:
      type: apiKey
      in: path
      name: token
      description: |
        The per-business ingest token, taken from the Leadmend dashboard — it is
        the last segment of the business's form link.

        **It is not a password, and should not be treated as one.** It appears
        in the embed snippet, in the page source of the customer's own website,
        and in the form URL. It grants exactly one capability: submit an enquiry
        to one business. It cannot read leads, cannot change settings, and
        cannot be used to authenticate anywhere else.

        The consequence of a leaked token is spurious enquiries to that
        business, bounded by the hourly cap. Rotate it from the dashboard if
        that happens.

security:
  - IngestToken: []
