openapi: 3.1.0
info:
  title: TextMeFlow API
  version: "0.1.0"
  summary: Self-hosted WhatsApp messaging API for the Fades Management product family.
  description: |
    TextMeFlow is a drop-in replacement for TextMeBot, plus a modern REST API.

    ## Authentication
    All `/v1/*` endpoints (except `POST /v1/accounts` and the Stripe webhook)
    require a Bearer token:
    ```
    Authorization: Bearer tmf_<your_api_key>
    ```

    The legacy `GET /send.php` endpoint accepts the api key as the
    `?apikey=…` query parameter for TextMeBot drop-in compatibility.
servers:
  - url: https://api.textmeflow.eu
    description: Production
  - url: http://localhost:3000
    description: Local

tags:
  - name: Compat
    description: TextMeBot drop-in compatibility endpoints.
  - name: Accounts
    description: Account, plan and pairing flows.
  - name: Messages
    description: Send + status of outbound messages.
  - name: Webhooks
    description: Inbound webhooks from Stripe (incoming) and customer-facing inbound-message webhooks (outgoing).
  - name: Admin
    description: Super-admin (HTTP Basic auth).

paths:
  /healthz:
    get:
      summary: Liveness probe.
      tags: []
      responses:
        "200": { description: ok }

  /send.php:
    get:
      tags: [Compat]
      summary: TextMeBot drop-in send.
      description: |
        Mirrors `https://api.textmebot.com/send.php`. Returns an HTML body
        containing `Result: <b>Success</b>` or `Result: <b>Failed</b>`.
        Existing BedFlow code that does substring-matching on that string
        keeps working unmodified.
      parameters:
        - in: query
          name: recipient
          required: true
          schema: { type: string, example: "+32472932208" }
        - in: query
          name: apikey
          required: true
          schema: { type: string }
        - in: query
          name: text
          required: true
          schema: { type: string }
      responses:
        "200":
          description: TextMeBot-formatted plain-HTML body.
          content:
            text/html:
              schema: { type: string }

  /v1/accounts:
    post:
      tags: [Accounts]
      summary: Register a new account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, plan_code]
              properties:
                email: { type: string, format: email }
                plan_code:
                  type: string
                  enum: [free, starter, pro, business, enterprise]
                interval:
                  type: string
                  enum: [monthly, yearly]
                  default: monthly
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account_id: { type: string, format: uuid }
                  api_key: { type: string }
                  plan: { type: string }
                  checkout_url:
                    type: [string, "null"]
                    description: Present for paid plans, null for free.
                  trial_ends_at: { type: [string, "null"], format: date-time }

  /v1/accounts/me:
    get:
      tags: [Accounts]
      summary: Current account profile + usage.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Account data.

  /v1/accounts/me/pair:
    post:
      tags: [Accounts]
      summary: Start a QR-pairing flow.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string }
      responses:
        "201":
          description: |
            Returns `qr_png_b64` (data URI) + `pair_id`. Poll
            `/v1/accounts/me/pair/{pair_id}/status` to see when WhatsApp
            confirms the link.

  /v1/accounts/me/pair/{pair_id}/status:
    get:
      tags: [Accounts]
      summary: Poll the pairing status.
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: pair_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: pending | connected | expired | failed

  /v1/accounts/me/session:
    delete:
      tags: [Accounts]
      summary: Log out of WhatsApp Web on this account.
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: Disconnected. }

  /v1/accounts/me/billing-portal:
    get:
      tags: [Accounts]
      summary: Stripe customer-portal URL for self-service.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Portal URL.

  /v1/messages:
    post:
      tags: [Messages]
      summary: Queue an outbound message.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, text]
              properties:
                to:
                  type: string
                  description: E.164 phone number (must start with `+`).
                  example: "+32472932208"
                text: { type: string }
                media_url: { type: string, format: uri }
                urgent:
                  type: boolean
                  description: Bypass quiet-hours shaping. Use sparingly.
                  default: false
      responses:
        "202":
          description: Queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id: { type: string, format: uuid }
                  status: { type: string, enum: [queued] }
        "429":
          description: Quota or rate limit exceeded.

  /v1/messages/{id}:
    get:
      tags: [Messages]
      summary: Fetch a message by id.
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Message record.

  /v1/webhooks/stripe:
    post:
      tags: [Webhooks]
      summary: Stripe webhook receiver.
      description: |
        Validates the `Stripe-Signature` header. All other auth bypassed —
        signed payload is the credential.
      responses:
        "200":
          description: received

  /admin/accounts:
    get:
      tags: [Admin]
      summary: List accounts (filterable).
      security: [{ basicAuth: [] }]
      parameters:
        - in: query
          name: status
          schema: { type: string, enum: [active, suspended, cancelled, past_due] }
        - in: query
          name: plan
          schema: { type: string }
        - in: query
          name: q
          schema: { type: string }
      responses:
        "200": { description: ok }

  /admin/accounts/{id}/suspend:
    post:
      tags: [Admin]
      summary: Suspend an account.
      security: [{ basicAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: suspended }

  /admin/accounts/{id}/reinstate:
    post:
      tags: [Admin]
      summary: Reinstate a suspended account.
      security: [{ basicAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: active }

  /admin/metrics:
    get:
      tags: [Admin]
      summary: Prometheus exposition.
      security: [{ basicAuth: [] }]
      responses:
        "200":
          description: text/plain Prometheus metrics.
          content:
            text/plain:
              schema: { type: string }

webhooks:
  inboundMessage:
    post:
      tags: [Webhooks]
      summary: Inbound WhatsApp message (TextMeFlow → your endpoint).
      description: |
        TextMeFlow POSTs this body to your configured `webhook_url` for every
        inbound WhatsApp message on a paired number. The shape is identical
        to TextMeBot's inbound webhook so existing integrations port 1-to-1.

        ## Authentication
        We sign the raw body with your `webhook_secret` (HMAC-SHA256) and
        send it as `X-TextMeFlow-Signature: sha256=<hex>`. Verify before
        trusting the payload — see https://textmeflow.eu/docs/webhooks
        for verify snippets in PHP / Node / Python.

        ## Idempotency
        `X-TextMeFlow-Event-Id` is stable across retries. Use it to dedupe.

        ## Retries
        Retried up to 5× (backoff 1m, 5m, 30m, 2h, 6h) on 408, 429, 5xx
        and network errors. Other 4xx fail immediately. Timeout is 10 s.
      parameters:
        - in: header
          name: X-TextMeFlow-Event-Id
          required: true
          schema: { type: string, format: uuid }
          description: Stable across retries — use for idempotency.
        - in: header
          name: X-TextMeFlow-Signature
          required: true
          schema: { type: string, example: "sha256=abc123…" }
          description: HMAC-SHA256 of the raw body, prefixed with `sha256=`.
        - in: header
          name: X-TextMeFlow-Delivery
          required: true
          schema: { type: string, format: uuid }
          description: Per-attempt id, useful for log grep.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InboundMessage'
            examples:
              text:
                summary: Text message
                value:
                  type: text
                  from: "32472932208"
                  from_name: "Jan Janssens"
                  to: "32499123456"
                  file: "null"
                  message: "Bedankt voor de bevestiging!"
              image:
                summary: Image with caption (file URL is "null" until media-rehosting ships)
                value:
                  type: image
                  from: "32472932208"
                  from_name: "Jan Janssens"
                  to: "32499123456"
                  file: "null"
                  message: "Foto van het terras"
      responses:
        "2XX":
          description: Acknowledged. Anything in the 2xx range counts as delivered.
        "401":
          description: Signature mismatch (your endpoint rejected). Will NOT be retried.
        "408":
          description: Timeout — will be retried.
        "429":
          description: Rate-limited — will be retried.
        "5XX":
          description: Server error — will be retried up to 5 times.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: tmf_<hex>
    basicAuth:
      type: http
      scheme: basic
  schemas:
    InboundMessage:
      type: object
      description: TextMeBot-compatible inbound-message envelope.
      required: [type, from, from_name, to, file, message]
      properties:
        type:
          type: string
          enum: [text, image, audio, video, document, location]
        from:
          type: string
          description: Sender, digits only (no leading +).
          example: "32472932208"
        from_name:
          type: string
          description: WhatsApp pushName, may be empty.
          example: "Jan Janssens"
        to:
          type: string
          description: Your paired number, digits only.
          example: "32499123456"
        file:
          type: string
          description: |
            Literal string `"null"` for text messages (matches TextMeBot's
            wire shape). Media-URL placeholder for non-text types — actual
            media-rehosting ships in a later release.
          example: "null"
        message:
          type: string
          description: |
            Text body or caption. For `document`, the filename. For
            `location`, the string `"<lat>,<lng>"`.
