openapi: 3.0.3
info:
  title: Redbean8 Messaging API
  version: "1.0.0"
  description: |
    SMS · LMS · MMS · 알림톡 · 브랜드메시지 발송 및 결과 조회 API.
    매뉴얼: https://user-01.redbean8.io.kr/intro/api/quickstart
servers:
  - url: https://api-01.redbean8.io.kr/v1
security:
  - bearerAuth: []
tags:
  - name: Messages
  - name: Files
  - name: Account
  - name: Kakao
paths:
  /health:
    get:
      summary: 상태 확인 (인증 불필요)
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data:
                    type: object
                    properties:
                      status: { type: string, example: ok }
                      time: { type: string, format: date-time }
  /messages/sms:
    post:
      tags: [Messages]
      summary: SMS 발송 (90 byte 이하)
      operationId: sendSms
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendNormalRequest" }
      responses:
        "200": { $ref: "#/components/responses/DryRun" }
        "202": { $ref: "#/components/responses/Accepted" }
        default: { $ref: "#/components/responses/Error" }
  /messages/lms:
    post:
      tags: [Messages]
      summary: LMS 발송 (제목 필수, 본문 2000자 이하)
      operationId: sendLms
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendNormalRequest" }
      responses:
        "200": { $ref: "#/components/responses/DryRun" }
        "202": { $ref: "#/components/responses/Accepted" }
        default: { $ref: "#/components/responses/Error" }
  /messages/mms:
    post:
      tags: [Messages]
      summary: MMS 발송 (file_ids 1~3개 필수)
      operationId: sendMms
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendNormalRequest" }
      responses:
        "200": { $ref: "#/components/responses/DryRun" }
        "202": { $ref: "#/components/responses/Accepted" }
        default: { $ref: "#/components/responses/Error" }
  /messages/alimtalk:
    post:
      tags: [Messages]
      summary: 알림톡 발송 (사업자 회원)
      operationId: sendAlimtalk
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendAlimtalkRequest" }
      responses:
        "200": { $ref: "#/components/responses/DryRun" }
        "202": { $ref: "#/components/responses/Accepted" }
        default: { $ref: "#/components/responses/Error" }
  /messages/brand:
    post:
      tags: [Messages]
      summary: 브랜드메시지 발송 (사업자 회원, 예약 취소 불가)
      operationId: sendBrand
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendBrandRequest" }
      responses:
        "200": { $ref: "#/components/responses/DryRun" }
        "202": { $ref: "#/components/responses/Accepted" }
        default: { $ref: "#/components/responses/Error" }
  /messages:
    get:
      tags: [Messages]
      summary: 발송 목록 (최근 7일 기본, 최대 31일)
      operationId: listMessages
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - name: status
          in: query
          schema: { type: string, enum: [ACCEPTED, SCHEDULED, PROCESSING, PARTIAL, COMPLETED, CANCELLED] }
        - name: channel
          in: query
          schema: { type: string, enum: [sms, lms, mms, alimtalk, brand] }
        - { name: client_ref, in: query, description: 지정하면 기간 조건을 무시하고 참조키로 검색, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Message" }
                  paging: { $ref: "#/components/schemas/Paging" }
        default: { $ref: "#/components/responses/Error" }
  /messages/{message_id}:
    get:
      tags: [Messages]
      summary: 발송 건 상세
      operationId: getMessage
      parameters: [{ $ref: "#/components/parameters/MessageId" }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data: { $ref: "#/components/schemas/Message" }
        default: { $ref: "#/components/responses/Error" }
  /messages/{message_id}/recipients:
    get:
      tags: [Messages]
      summary: 수신자별 결과 (폴링)
      operationId: listRecipients
      parameters:
        - { $ref: "#/components/parameters/MessageId" }
        - name: status
          in: query
          schema: { type: string, enum: [PENDING, SENDING, SUBMITTED, DELIVERED, FAILED, REJECTED, CANCELLED] }
        - { name: to, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Recipient" }
                  paging: { $ref: "#/components/schemas/Paging" }
        default: { $ref: "#/components/responses/Error" }
  /messages/{message_id}/cancel:
    post:
      tags: [Messages]
      summary: 예약 취소 (발송 전 수신자만, 요금 환불)
      operationId: cancelMessage
      parameters: [{ $ref: "#/components/parameters/MessageId" }]
      responses:
        "200":
          description: 취소됨
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data:
                    type: object
                    properties:
                      message_id: { type: integer }
                      cancelled: { type: integer }
                      refunded: { type: number }
                      not_cancellable: { type: integer }
        default: { $ref: "#/components/responses/Error" }
  /files/mms:
    post:
      tags: [Files]
      summary: MMS 이미지 업로드 (jpg/png, 5MB 이하, 7일간 유효)
      operationId: uploadMmsFile
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data:
                    type: object
                    properties:
                      file_id: { type: integer }
                      original_name: { type: string }
                      mime_type: { type: string }
                      size: { type: integer }
                      expires_at: { type: string, format: date-time }
        default: { $ref: "#/components/responses/Error" }
  /account/balance:
    get:
      tags: [Account]
      summary: 잔액 조회
      operationId: getBalance
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data:
                    type: object
                    properties:
                      balance: { type: number }
                      currency: { type: string, example: KRW }
        default: { $ref: "#/components/responses/Error" }
  /send-numbers:
    get:
      tags: [Account]
      summary: 발신번호 목록 (api_enabled=true 만 API 발송 가능)
      operationId: listSendNumbers
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        number: { type: string }
                        alias: { type: string, nullable: true }
                        status: { type: string }
                        api_enabled: { type: boolean }
        default: { $ref: "#/components/responses/Error" }
  /kakao/profiles:
    get:
      tags: [Kakao]
      summary: 카카오 발신프로필 목록
      operationId: listKakaoProfiles
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        profile: { type: string, description: 발신프로필 키 }
                        channel_name: { type: string, nullable: true }
                        plus_friend_id: { type: string, nullable: true }
                        usable: { type: boolean }
                        brand_message: { type: boolean }
        default: { $ref: "#/components/responses/Error" }
  /kakao/templates:
    get:
      tags: [Kakao]
      summary: 발송 가능한 템플릿 목록
      operationId: listKakaoTemplates
      parameters:
        - { name: profile, in: query, required: true, schema: { type: string } }
        - name: channel
          in: query
          schema: { type: string, enum: [alimtalk, brand], default: alimtalk }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        template_code: { type: string }
                        name: { type: string }
                        content: { type: string }
                        bubble_type: { type: string, description: 브랜드 템플릿만 }
                        variables:
                          type: array
                          items: { type: string }
        default: { $ref: "#/components/responses/Error" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "`ak_` 로 시작하는 API 키 (웹사이트 > API 연동에서 발급)"
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: 같은 키로 재요청하면 중복 발송 없이 이전 결과를 반환 (영숫자 _-.:, 1~64자, 90일 보관)
      schema: { type: string, maxLength: 64 }
    MessageId:
      name: message_id
      in: path
      required: true
      schema: { type: integer }
  responses:
    Accepted:
      description: 접수됨 (결과는 비동기로 확정)
      headers:
        Idempotent-Replayed:
          description: 멱등 재생 응답이면 true
          schema: { type: string }
      content:
        application/json:
          schema:
            type: object
            properties:
              request_id: { type: string }
              data: { $ref: "#/components/schemas/AcceptedData" }
    DryRun:
      description: dry_run=true 검증 결과 (발송·과금 없음)
      content:
        application/json:
          schema:
            type: object
            properties:
              request_id: { type: string }
              data:
                type: object
                properties:
                  dry_run: { type: boolean }
                  channel: { type: string }
                  accepted: { type: integer }
                  rejected_count: { type: integer }
                  rejected:
                    type: array
                    items: { $ref: "#/components/schemas/Rejected" }
                  amount: { type: number }
    Error:
      description: 오류
      headers:
        Retry-After:
          description: 429/503 일 때 재시도까지 대기할 초
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        request_id: { type: string }
        error:
          type: object
          properties:
            code:
              type: string
              example: INVALID_REQUEST
            message: { type: string }
            details: { type: object, additionalProperties: true }
    Rejected:
      type: object
      properties:
        index: { type: integer }
        to: { type: string }
        reason: { type: string, enum: [INVALID_NUMBER, DUPLICATE, OPTED_OUT] }
    AcceptedData:
      type: object
      properties:
        message_id: { type: integer }
        channel: { type: string, enum: [sms, lms, mms, alimtalk, brand] }
        status: { type: string, enum: [ACCEPTED, SCHEDULED, COMPLETED, PROCESSING, PARTIAL, CANCELLED] }
        accepted: { type: integer }
        rejected_count: { type: integer }
        rejected:
          type: array
          items: { $ref: "#/components/schemas/Rejected" }
        amount: { type: number }
        send_at: { type: string, format: date-time, nullable: true }
        client_ref: { type: string, nullable: true }
        replayed: { type: boolean }
    Paging:
      type: object
      properties:
        next_cursor: { type: string, nullable: true }
    Message:
      type: object
      properties:
        message_id: { type: integer }
        channel: { type: string }
        status: { type: string, enum: [ACCEPTED, SCHEDULED, PROCESSING, PARTIAL, COMPLETED, CANCELLED] }
        name: { type: string, nullable: true }
        from: { type: string, nullable: true }
        client_ref: { type: string, nullable: true }
        request_id: { type: string, nullable: true }
        send_at: { type: string, format: date-time, nullable: true }
        counts:
          type: object
          properties:
            total: { type: integer }
            delivered: { type: integer }
            failed: { type: integer }
            pending: { type: integer }
        amount: { type: number }
        created_at: { type: string, format: date-time }
    Recipient:
      type: object
      properties:
        recipient_id: { type: integer }
        to: { type: string }
        status: { type: string, enum: [PENDING, SENDING, SUBMITTED, DELIVERED, FAILED, REJECTED, CANCELLED] }
        client_ref: { type: string, nullable: true }
        result:
          type: object
          properties:
            code: { type: string, nullable: true }
            message: { type: string, nullable: true }
            carrier: { type: string, nullable: true }
        send_at: { type: string, format: date-time, nullable: true }
        sent_at: { type: string, format: date-time, nullable: true }
        delivered_at: { type: string, format: date-time, nullable: true }
        charged: { type: number }
    RecipientInput:
      type: object
      required: [to]
      properties:
        to: { type: string }
        text: { type: string, description: 이 수신자 전용 본문 (문자) }
        ref: { type: string, maxLength: 64 }
        variables: { type: object, additionalProperties: { type: string } }
    CommonSend:
      type: object
      properties:
        to:
          type: array
          maxItems: 1000
          items: { type: string }
          description: "recipients 와 택1. 동일 문구. 최대 1,000명"
        recipients:
          type: array
          maxItems: 1000
          items: { $ref: "#/components/schemas/RecipientInput" }
          description: "수신자별 문구·변수. SMS/알림톡 개별 1,000명, LMS/MMS 개별 문구 500명 (초과 시 TOO_MANY_RECIPIENTS)"
        send_at: { type: string, format: date-time, description: ISO 8601. 오프셋 없으면 KST. 현재~180일 이내 }
        name: { type: string, maxLength: 200 }
        exclude_optout: { type: boolean, default: true }
        client_ref: { type: string, maxLength: 64 }
        dry_run: { type: boolean, default: false }
    SendNormalRequest:
      allOf:
        - $ref: "#/components/schemas/CommonSend"
        - type: object
          required: [from]
          properties:
            from: { type: string, description: 승인되고 API 허용된 발신번호 }
            text: { type: string, maxLength: 4000, description: "SMS 90byte / LMS·MMS 2000자 (초과 시 MESSAGE_TOO_LONG)" }
            subject: { type: string, description: LMS 필수(30자 이하) }
            ad: { type: boolean, default: false, description: "(광고) 및 080 수신거부 문구 자동 삽입" }
            file_ids:
              type: array
              minItems: 1
              maxItems: 3
              items: { type: integer }
              description: MMS 필수. POST /files/mms 로 발급
    SendAlimtalkRequest:
      allOf:
        - $ref: "#/components/schemas/CommonSend"
        - type: object
          required: [profile, template_code]
          properties:
            profile: { type: string, description: 발신프로필 키 }
            template_code: { type: string }
            variables: { type: object, additionalProperties: { type: string } }
    SendBrandRequest:
      allOf:
        - $ref: "#/components/schemas/CommonSend"
        - type: object
          required: [profile]
          properties:
            profile: { type: string }
            mode: { type: string, enum: [template, free], default: template }
            template_code: { type: string, description: mode=template 필수 }
            text: { type: string, maxLength: 1300, description: mode=free 필수 }
            variables: { type: object, additionalProperties: { type: string } }
            target: { type: string, enum: [friend, marketing], default: friend }
            targeting: { type: string, enum: [M, N, O], description: target=marketing 필수 }
            unsubscribe_phone: { type: string }
            unsubscribe_auth: { type: string }
            group_tag_key: { type: string }
            push_alarm: { type: boolean, default: true }
            adult: { type: boolean, default: false }
x-webhooks:
  message.batch:
    description: |
      결과 Webhook. 고객사 URL 로 POST (HTTPS 443). 서명:
      `X-Redbean-Signature: v1=hex(HMAC_SHA256(secret, X-Redbean-Timestamp + "." + raw_body))`
      헤더: X-Redbean-Event, X-Redbean-Delivery-Id, X-Redbean-Timestamp.
      2xx 응답 시 성공. 실패 시 30초·2분·10분·30분·2시간·6시간·12시간·24시간 후 재시도.
    payload:
      type: object
      properties:
        id: { type: string }
        type: { type: string, example: message.batch }
        created_at: { type: string, format: date-time }
        events:
          type: array
          maxItems: 100
          items:
            type: object
            properties:
              event: { type: string, enum: [message.delivered, message.failed, message.rejected, message.cancelled] }
              message_id: { type: integer }
              recipient_id: { type: integer }
              channel: { type: string }
              to: { type: string }
              status: { type: string, enum: [DELIVERED, FAILED, REJECTED, CANCELLED] }
              client_ref: { type: string, nullable: true }
              request_client_ref: { type: string, nullable: true }
              result:
                type: object
                properties:
                  code: { type: string, nullable: true }
                  message: { type: string, nullable: true }
                  carrier: { type: string, nullable: true }
              delivered_at: { type: string, format: date-time, nullable: true }
