openapi: 3.0.3
info:
  title: MAAC Go API
  version: "1.0.0"
  description: |
    MAAC Go — Taiwan-first self-serve SMS platform. Send transactional (OTP /
    order / reminder) and broadcast SMS with NCC compliance, MAAC delivery
    backend, and real-time DLR webhooks.

    - Base URL: `https://sms.cresclab.com/api`
    - Auth: `Authorization: Bearer <sk_live_... or sk_test_...>`
    - New accounts receive NT$50 trial credit on signup, enough to send real test SMS immediately.
    - API keys are scoped and wallet-backed; sending debits the account balance unless your deployment uses a mock SMS/payment adapter.
    - All phone numbers use E.164 (`+886912345678`). 09xxxxxxxx is also accepted.
    - Cost: NT$0.78 per SMS segment (Chinese: 70 chars/segment · English: 160/segment).
    - Rate-limit errors return `429 rate_limited` with limit/used details and retry guidance when available.
    - Delivery confirmations arrive asynchronously via webhook (`sms.delivered`
      / `sms.failed`); poll `GET /sms/{id}` as a fallback.
    - MCP clients (Claude / Cursor / Windsurf) can use this spec directly, or
      connect via `POST /api/mcp` (JSON-RPC 2.0) with the same bearer key.
  contact:
    name: MAAC Go Support
    email: info@cresclab.com
    url: https://sms.cresclab.com
  license:
    name: Commercial
    url: https://sms.cresclab.com/terms.html
servers:
  - url: https://sms.cresclab.com/api
    description: Production
security:
  - bearerAuth: []
tags:
  - name: SMS
    description: Transactional (1-to-1) SMS send + status.
  - name: Broadcast
    description: Bulk (1-to-many) SMS. Small batches dispatch inline; >30 recipients queue asynchronously.
  - name: Contacts
    description: Address book with NCC-consent tracking.
  - name: Webhooks
    description: Delivery events pushed to your endpoint (HMAC-SHA256 signed).
  - name: Wallet
    description: Prepaid balance and transaction log.

paths:
  /sms/send:
    post:
      tags: [SMS]
      summary: Send a transactional SMS (1-to-1)
      description: |
        OTP, order notifications, appointment reminders. High-priority channel,
        no LINE fallback. Returns after MAAC accepts the request; final delivery
        status arrives via the `sms.delivered` / `sms.failed` webhook.
      operationId: sendSms
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SmsSendRequest' }
            examples:
              otp:
                value: { to: "+886912345678", body: "【YourBrand】驗證碼 483291，5 分鐘內有效。勿告知他人。", type: "otp" }
              notification:
                value: { to: "+886912345678", body: "【YourBrand】訂單 A12345 已出貨，預計 3/15 送達。", type: "notification" }
      responses:
        "200":
          description: Queued for delivery
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SmsSendResponse' }
        "400":
          description: Validation or NCC compliance failure
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              examples:
                ncc:
                  value: { error: "ncc_blocked", issues: [{ level: "block", code: "SHORTENER", reason: "bit.ly is on NCC 2024/11 blocklist" }] }
        "401": { description: "Auth missing / invalid", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "402": { description: "Wallet balance insufficient", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "429": { description: "Rate limit exceeded", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }

  /sms/{id}:
    get:
      tags: [SMS]
      summary: Retrieve SMS status
      description: Poll once per 5+ seconds as a fallback; prefer the `sms.delivered` webhook.
      operationId: getSms
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
          example: "sms_abc123"
      responses:
        "200":
          description: Current message state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SmsDetail' }
        "404": { description: "Not found" }

  /sms/list:
    get:
      tags: [SMS]
      summary: List recent messages (Resend-style delivery log)
      operationId: listSms
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: status
          schema: { type: string, enum: [queued, sent, delivered, failed, stop] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  messages: { type: array, items: { $ref: '#/components/schemas/SmsDetail' } }
                  summary_7d: { type: object, additionalProperties: { type: integer } }

  /sms/metrics:
    get:
      tags: [SMS]
      summary: Daily volume + delivery metrics
      operationId: getMetrics
      parameters:
        - in: query
          name: days
          schema: { type: integer, default: 30, maximum: 90 }
      responses:
        "200":
          description: Aggregated metrics
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MetricsResponse' }

  /broadcast:
    post:
      tags: [Broadcast]
      summary: Create a broadcast (1-to-many)
      description: |
        ≤ 30 recipients dispatch inline and return 200 with `delivered` / `failed` counts.
        \> 30 recipients queue async and return 202 with `status: "queued"`; cron dispatches in
        batches of 40 every 5 min; DLR webhooks flip individual messages.

        Schedule for later by passing `scheduled_at` (ISO 8601); funds reserve immediately,
        actual charge happens at send time.
      operationId: createBroadcast
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BroadcastCreateRequest' }
      responses:
        "200":
          description: Sent inline (small broadcast)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BroadcastSentResponse' }
        "202":
          description: Queued for async dispatch (large broadcast)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BroadcastQueuedResponse' }
        "400": { description: "recipients_required / invalid_phones / ncc_blocked / too_many_recipients" }
        "402": { description: "insufficient_balance" }
        "429": { description: "rate_limited" }
    get:
      tags: [Broadcast]
      summary: List broadcasts with per-broadcast delivery stats
      operationId: listBroadcasts
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  broadcasts: { type: array, items: { $ref: '#/components/schemas/BroadcastSummary' } }

  /contacts:
    get:
      tags: [Contacts]
      summary: List contacts
      operationId: listContacts
      parameters:
        - { in: query, name: search, schema: { type: string } }
        - { in: query, name: tag,    schema: { type: string } }
        - { in: query, name: page,   schema: { type: integer, default: 1 } }
        - { in: query, name: limit,  schema: { type: integer, default: 50, maximum: 500 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  data: { type: array, items: { $ref: '#/components/schemas/Contact' } }
                  total: { type: integer }
                  page:  { type: integer }
                  limit: { type: integer }
    post:
      tags: [Contacts]
      summary: Create a contact (single) or bulk
      description: |
        Single mode: `{ phone, first_name?, last_name?, tags?, consent_source? }`
        Bulk mode: `{ contacts: [...], consent_source? }` — up to 10,000 per call.

        `consent_source` is required for NCC compliance. Duplicate phones within a batch
        are deduped; existing `(user_id, phone)` pairs are skipped.
      operationId: createContact
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/ContactCreateSingle'
                - $ref: '#/components/schemas/ContactCreateBulk'
      responses:
        "201":
          description: Created (single or bulk stats)

webhooks:
  sms.delivered:
    post:
      summary: SMS reached the recipient's handset
      description: |
        Delivered to your webhook endpoint when MAAC's DLR confirms delivery.
        Sign with HMAC-SHA256: `X-Cresclab-Signature: <hex_digest>` where
        `hex_digest = HMAC_SHA256(your_webhook_secret, raw_request_body)`.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        "200": { description: "ACK with any 2xx" }
  sms.failed:
    post:
      summary: Final delivery failure (number invalid / blocked / undeliverable)
      description: Wallet is auto-refunded for failed messages.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        "200": { description: "ACK with any 2xx" }
  sms.sent:
    post:
      summary: Message accepted by carrier (interim state)
      requestBody:
        content: { application/json: { schema: { $ref: '#/components/schemas/WebhookEvent' } } }
      responses:
        "200": { description: "ACK" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <api_key>`. Production keys start with `sk_live_`,
        test keys with `sk_test_` for environment separation. Sending still follows
        the account wallet and deployment adapter settings.

  schemas:
    SmsSendRequest:
      type: object
      required: [to, body]
      properties:
        to:
          type: string
          description: E.164 (`+886912345678`) or TW local (`0912345678`)
          example: "+886912345678"
        body:
          type: string
          maxLength: 1000
          description: SMS text. 70 chars = 1 segment (Chinese), 160 (ASCII). Must include `【brand】` prefix and `STOP` / `退訂` for marketing.
        from:
          type: string
          description: Sender ID. Defaults to Cresclab shared short-code `1990`.
        type:
          type: string
          enum: [otp, notification, marketing]
          description: Controls routing priority and compliance checks.

    SmsSendResponse:
      type: object
      properties:
        ok: { type: boolean }
        message_id: { type: string, example: "sms_abc123" }
        status: { type: string, enum: [queued, sent, delivered, failed] }
        segments: { type: integer }
        cost_cents: { type: integer, description: "Amount debited in NT cents (78 = NT$0.78)" }
        balance_cents: { type: integer }

    SmsDetail:
      type: object
      properties:
        id: { type: string }
        to: { type: string }
        body: { type: string }
        status: { type: string, enum: [queued, sent, delivered, failed, stop] }
        segments: { type: integer }
        cost_cents: { type: integer }
        sent_at: { type: string, format: date-time, nullable: true }
        delivered_at: { type: string, format: date-time, nullable: true }
        error: { type: string, nullable: true }
        gateway_ref: { type: string, nullable: true, description: "Upstream MAAC pnp_message_id" }

    BroadcastCreateRequest:
      type: object
      required: [body, recipients]
      properties:
        name: { type: string, description: "Internal label for the campaign" }
        body: { type: string, description: "Same NCC rules as /sms/send" }
        recipients:
          type: array
          items: { type: string, description: "E.164 or 09xxxxxxxx" }
          minItems: 1
          maxItems: 10000
        scheduled_at:
          type: string
          format: date-time
          description: "ISO 8601. Omit to send immediately."
        from: { type: string }

    BroadcastSentResponse:
      type: object
      properties:
        ok: { type: boolean }
        broadcast_id: { type: string }
        status: { type: string, enum: [sent] }
        recipient_count: { type: integer }
        delivered: { type: integer, description: "Accepted by carrier — final status via webhook" }
        failed: { type: integer }
        cost_cents: { type: integer }
        refund_cents: { type: integer }
        balance_cents: { type: integer }

    BroadcastQueuedResponse:
      type: object
      properties:
        ok: { type: boolean }
        broadcast_id: { type: string }
        status: { type: string, enum: [queued] }
        recipient_count: { type: integer }
        cost_cents: { type: integer }
        dispatch: { type: string, enum: [async] }

    BroadcastSummary:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        recipient_count: { type: integer }
        cost_cents: { type: integer }
        status: { type: string, enum: [scheduled, sending, sent, failed] }
        delivered_count: { type: integer }
        failed_count: { type: integer }
        sent_count: { type: integer }
        created_at: { type: string, format: date-time }
        scheduled_at: { type: string, format: date-time, nullable: true }
        finished_at: { type: string, format: date-time, nullable: true }

    Contact:
      type: object
      properties:
        id: { type: integer }
        phone: { type: string, description: "09xxxxxxxx (normalized)" }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        tags: { type: array, items: { type: string } }
        consent_source: { type: string, example: "signup_form" }
        created_at: { type: string, format: date-time }

    ContactCreateSingle:
      type: object
      required: [phone, consent_source]
      properties:
        phone: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        tags: { type: array, items: { type: string } }
        consent_source:
          type: string
          description: "Where you collected consent: signup_form / csv_import / api / manual / custom"

    ContactCreateBulk:
      type: object
      required: [contacts]
      properties:
        consent_source: { type: string, default: "csv_import" }
        contacts:
          type: array
          maxItems: 10000
          items:
            type: object
            required: [phone]
            properties:
              phone: { type: string }
              first_name: { type: string }
              last_name: { type: string }
              tags: { type: array, items: { type: string } }

    MetricsResponse:
      type: object
      properties:
        ok: { type: boolean }
        days: { type: integer }
        daily:
          type: array
          items:
            type: object
            properties:
              day: { type: string, format: date }
              total: { type: integer }
              delivered: { type: integer }
              failed: { type: integer }
              cost_cents: { type: integer }
        totals:
          type: object
          properties:
            total: { type: integer }
            delivered: { type: integer }
            failed: { type: integer }
            cost_cents: { type: integer }
            avg_latency_sec: { type: number, nullable: true }

    WebhookEvent:
      type: object
      description: |
        Outbound event sent to your configured webhook URL. Verify `X-Cresclab-Signature`
        header: `hmac_sha256(your_webhook_secret, raw_body_bytes).hex() == header_value`.
      properties:
        event: { type: string, enum: [sms.sent, sms.delivered, sms.failed] }
        data:
          type: object
          properties:
            id: { type: string, example: "sms_abc123" }
            to: { type: string }
            status: { type: string }
            error: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

    ErrorResponse:
      type: object
      properties:
        error: { type: string, example: "ncc_blocked" }
        hint: { type: string }
        issues:
          type: array
          items:
            type: object
            properties:
              level: { type: string, enum: [block, warn] }
              code: { type: string }
              reason: { type: string }
