openapi: 3.1.0
info:
  title: throw docs — 공유 API
  version: 1.0.0
  description: |
    비즈니스 플랜 계정이 자기 명의로 문서 공유를 자동화할 수 있는 공개 API.

    - 인증: 마이페이지에서 발급한 개인 API 토큰(PAT). `Authorization: Bearer tdk_live_…`
    - 토큰에 고정 만료일은 없지만 90일간 사용하지 않으면 자동 회수된다. 마이페이지에서도 언제든 회수할 수 있다.
    - 발송(메일·메시지)은 호출자 몫이다 — API 는 링크만 만들어 돌려준다.
    - 쓰기: 분당 5회 / 읽기: 분당 60회 (토큰당). 초과 시 429 + Retry-After.
    - POST /shares 는 Idempotency-Key 필수 — 재시도가 문서를 두 번 보내지 않는다.
    - 별도 샌드박스나 테스트 토큰은 없다. 아래 서버는 실제 환경이며 생성 결과와 사용량이 계정에 반영된다.

    선택 담당자 통계 메타데이터 (수집기와 로그 테이블 배포 후 수집):
    - 각 API 요청에 `X-CRM-Sender-Id: crm_Staff-001`을 선택적으로 보낼 수 있다.
      기존 연동은 변경할 필요가 없다. 미전달·형식 오류는 요청을 거절하지 않고 통계 식별자를 NULL로 처리한다.
    - CRM이 제공하는 비개인정보 식별자이며 인증 증명이 아니다. `Authorization`·플랜·스코프 검사는 그대로다.
      이름·이메일·IP·토큰 등 개인정보나 비밀값을 넣지 않는다. 서버는 형식만 검사하며 실제 담당자 신원을 확인하지 않는다.
    - 영문·숫자·밑줄·하이픈 1~128자만 수집하며 대소문자를 구분한다. 값을 잘라내거나 소문자로 바꾸지 않는다.
      토큰을 식별하지 못한 요청도 NULL이다. 식별된 회수·만료·권한 부족 토큰의 오류 요청에는 주장된 ID가 기록될 수 있다.
    - ID는 해당 계정의 해당 토큰 범위에서만 의미가 있다. 같은 ID라도 다른 토큰·계정과 합산하지 않는다.
      기존 `sender.name/email`은 고객 표시용이며 변경하지 않는다. 이름·이메일·본문의 ID에서 통계 식별자를 추정하지 않는다.
    - 요청 본문·응답·웹훅 계약 및 `Idempotency-Key` 판정에는 영향을 주지 않는다.
      재시도도 개별 API 호출로 수집되며, 공유 생성 건수나 실제 발송 건수를 뜻하지 않는다.
    - 검증한 ID만 요청 메타데이터 테이블에 저장한다. 헤더 원문·본문·URL 값·IP는 이 테이블에 저장하지 않는다.
      UTC 시각으로 저장하며 90일 초과 기록은 배치 삭제한다(삭제 지연 가능). 수집은 best effort이며 과거 기록의 담당자는 소급 복원하지 않는다.
servers:
  - url: https://doc.throw.im/api/v1
    description: 실제 운영 환경 — 별도 샌드박스 없음
security:
  - bearerAuth: []

paths:
  /me:
    parameters:
      - { $ref: '#/components/parameters/CrmSenderId' }
    get:
      summary: 토큰 검증 — 누구 명의인지, 무엇까지 되는지
      description: 연동 첫 단계에서 자격·플랜 한도를 확인한다.
      responses:
        '200':
          content:
            application/json:
              example:
                actor: { kind: pat, scopes: [documents:write, shares:write, shares:read] }
                plan:
                  code: business
                  # options.expiresInDays 에 넣을 수 있는 값.
                  # 허용값이 이산집합이라 상한만으로는 부족하다 — 상한 90 인 플랜에 30 을
                  # 보내면 상한 안인데도 400 이 난다(60 은 선택지가 아니다). allowedExpiresInDays 에서 고르면 된다.
                  maxExpiresInDays: 365
                  allowedExpiresInDays: [1, 7, 90, 365]
                  maxShareRecipients: null
                  maxUploadBytes: 262144000
                  screenGuardAvailable: true
        '401': { $ref: '#/components/responses/Unauthorized' }

  /documents:
    parameters:
      - { $ref: '#/components/parameters/CrmSenderId' }
    post:
      summary: PDF 등록
      description: |
        파일을 직접 올리거나(multipart), 파일이 있는 주소를 주면(JSON `url`) 우리가 받아 온다.
        **둘 중 하나만 보내야 한다** — 둘 다 보내면 400, 둘 다 없어도 400 이다.

        어느 쪽으로 넣든 결과는 같다. 같은 내용은 내부에서 dedup 되어 물리 저장은 1회이고,
        반환된 documentId 로 여러 번 공유할 수 있다. 플랜별 파일 크기·저장 용량 한도가 적용된다.

        url 로 주는 경우:
          · https 공인 주소만 받는다. 사설 IP·localhost 는 거절된다(SSRF 방어).
          · 리다이렉트는 3회까지 따라가며 홉마다 주소를 다시 검사한다.
            (S3 레거시 엔드포인트가 지역 엔드포인트로 307 을 준다)
          · PDF 판정은 Content-Type 이 아니라 실제 바이트로 한다. S3 는 업로드 시
            지정하지 않으면 binary/octet-stream 을 주는데, 그래도 정상 처리된다.
          · presigned URL 을 써도 된다. 쿼리의 서명은 오류 응답·로그에 남기지 않는다.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary, description: PDF 만 지원 }
                url: { type: string, description: file 대신 쓸 수 있다. 함께 보내면 400 }
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  description: PDF 가 있는 https 주소. S3 객체 주소나 presigned URL.
                  example: https://버킷.s3.ap-northeast-2.amazonaws.com/docs/계약서.pdf?X-Amz-Signature=…
                name:
                  type: string
                  description: |
                    저장할 파일명. 생략하면 주소 경로에서 뽑고, 그것도 없으면 document.pdf 다.
                    presigned URL 은 키가 UUID 인 경우가 많아 넣어 주는 편이 낫다.
                  example: 계약서.pdf
      responses:
        '201':
          content:
            application/json:
              example: { documentId: doc_pJbchjs_mQ4QYKrkjTDkiA, name: 표준계약서.pdf, sizeBytes: 148231, createdAt: '2026-08-27T00:00:00Z' }
        '400':
          description: |
            file_required(파일도 url 도 없음) · unsupported_file_type(PDF 아님)
            · document_fetch_failed(url 을 받지 못함 — 404·사설주소·https 아님·리다이렉트 초과·DNS 실패)
            · file 과 url 을 함께 보낸 경우
        '409': { description: storage_quota_exceeded — details 에 usedBytes · storageQuota 가 온다 }
        '413': { description: file_too_large }

  /documents/{id}:
    parameters:
      - { $ref: '#/components/parameters/CrmSenderId' }
      - { name: id, in: path, required: true, schema: { type: string }, example: doc_pJbchjs_mQ4QYKrkjTDkiA }
    get:
      summary: 문서 확인
      description: |
        등록해 둔 문서를 확인한다. 지우기 전에 무엇인지 보는 자리이기도 하다 —
        activeShares 가 0 이어야 force 없이 지울 수 있다.
      responses:
        '200':
          content:
            application/json:
              example:
                documentId: doc_pJbchjs_mQ4QYKrkjTDkiA
                name: 계약서.pdf
                sizeBytes: 148231
                createdAt: '2026-08-27T00:00:00Z'
                activeShares: 3
        '400': { description: bad_request — id 형식이 틀림 }
        '404': { description: document_not_found — 없거나 다른 계정 문서(구분해 알려주지 않는다) }
    delete:
      summary: 문서 삭제
      description: |
        문서를 휴지통으로 보낸다. **이 문서를 쓰는 고객 링크가 즉시 열리지 않게 된다** —
        뷰어가 asset_files.deleted_at IS NULL 로 조회하기 때문이다.

        그래서 회수되지 않고 만료도 안 된 공유가 남아 있으면 409 로 막고
        details.activeShares 에 몇 건인지 담는다. 공유를 먼저 회수하는 것이 정석이고,
        그래도 지워야 하면 force=true 를 명시한다.

        영구 삭제는 하지 않는다. 7일 뒤 정리 잡이 지우고 그 전에는 화면에서 복구할 수 있다.
      parameters:
        - name: force
          in: query
          required: false
          schema: { type: boolean }
          description: 살아 있는 공유가 있어도 지운다. 그 순간 고객 링크가 죽는다.
      responses:
        '200':
          content:
            application/json:
              example:
                id: doc_pJbchjs_mQ4QYKrkjTDkiA
                deleted: true
                activeShares: 0
                restorableUntil: '2026-09-09T00:00:00Z'
        '400': { description: bad_request — id 형식이 틀림 }
        '404': { description: document_not_found }
        '409': { description: document_in_use — details.activeShares 에 살아 있는 공유 수가 온다 }

  /webhooks:
    parameters:
      - { $ref: '#/components/parameters/CrmSenderId' }
    post:
      summary: 웹훅 등록
      x-required-scope: shares:read
      description: |
        열람·다운로드·만료·게스트 메모 생성을 연동처 서버로 밀어 준다. 토큰이 연결된 계정의 모든 공유에서 발동한다.

        url 은 https 공인 주소여야 한다 — 사설 IP·localhost·169.254.169.254 는 거절된다(SSRF 방어).
        별도 인증값 없이 본문의 shareId 를 귀사 발송 기록과 대조하고 X-Throw-Delivery 로 중복을 제거한다.
        자동 처리 전에 현재 상태를 확정해야 한다면 GET /shares/{id}로 다시 확인한다.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookRegisterRequest' }
      responses:
        '201':
          description: 등록 완료
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookRegistration' }
              example:
                id: whk_Fh7daz1oQZoxm61cuCHIVg
                name: CRM 알림
                url: https://crm.example.com/hooks/throw
                events: [share.opened, share.downloaded, share.expired, share.memo_created]
                status: active
                createdAt: '2026-08-27T09:00:00Z'
        # 계정당 상한을 넘기면 400 이고 details.limit 에 그때의 상한이 담긴다 — 숫자를 화면에 박지 말 것.
        '400': { description: bad_request — 잘못된 name·url·events 또는 등록 한도 초과(details.limit) }
        '403': { description: missing_scope — shares:read 권한 필요 }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      summary: 웹훅 목록
      x-required-scope: shares:read
      responses:
        '200':
          description: 웹훅 목록
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WebhookListItem' }
              example:
                - id: whk_Fh7daz1oQZoxm61cuCHIVg
                  name: CRM 알림
                  url: https://crm.example.com/hooks/throw
                  events: [share.opened, share.downloaded, share.expired, share.memo_created]
                  status: active
                  consecutiveFailures: 0
                  lastDeliveredAt: '2026-08-27T10:03:01Z'
                  createdAt: '2026-08-27T09:00:00Z'
        '403': { description: missing_scope — shares:read 권한 필요 }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}:
    parameters:
      - { $ref: '#/components/parameters/CrmSenderId' }
    delete:
      summary: 웹훅 해제
      x-required-scope: shares:read
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200':
          description: 해제 완료
          content:
            application/json:
              schema:
                type: object
                required: [ok, removedAt]
                properties:
                  ok: { type: boolean, const: true }
                  removedAt: { type: string, format: date-time }
              example: { ok: true, removedAt: '2026-08-27T11:00:00Z' }
        '403': { description: missing_scope — shares:read 권한 필요 }
        '404': { description: 형식이 잘못됐거나 남의 웹훅·없는 웹훅 모두 404 }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/deliveries:
    get:
      summary: 최근 웹훅 전달 이력
      x-required-scope: shares:read
      description: |
        실제 이벤트의 최근 전달 요청과 결과를 최신순으로 반환한다.
        response는 마지막 전송 결과다. pending 상태는 전송 예정 내용이며 수신 서버의 내부 처리 완료를 보장하지 않는다.
        테스트 전송은 DB에 기록하지 않으므로 이 목록에 포함되지 않는다.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, pattern: '^whk_' } }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: 등록 주소와 최근 전달 이력
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookDeliveryHistory' }
        '403': { description: missing_scope — shares:read 권한 필요 }
        '404': { description: 형식이 잘못됐거나 남의 웹훅·없는 웹훅 모두 404 }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/test:
    post:
      summary: 웹훅 테스트 전송
      x-required-scope: shares:read
      description: |
        등록 주소로 webhook.test 이벤트를 즉시 한 번 보낸다.
        공유나 업무 이벤트를 만들지 않고 테스트 전달 이력은 DB에 기록하지 않으며, 실패해도 재시도하지 않는다.
        기존 API 토큰 사용 기록은 갱신된다.
        실제 웹훅과 같은 Content-Type, X-Throw-Event, X-Throw-Delivery 헤더와 10초 제한을 사용한다.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, pattern: '^whk_' } }
      responses:
        '200':
          description: 수신 서버의 응답 여부와 실제 보낸 요청
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookTestResult' }
        '403': { description: missing_scope — shares:read 권한 필요 }
        '404': { description: 형식이 잘못됐거나 남의 웹훅·없는 웹훅 모두 404 }
        '429': { $ref: '#/components/responses/RateLimited' }

  /shares:
    parameters:
      - { $ref: '#/components/parameters/CrmSenderId' }
    post:
      summary: 문서 공유 생성 ⭐
      description: |
        세션을 만들고 수신자에게 보낼 링크를 돌려준다. 링크 발송은 호출자가 한다.

        문서를 지정하는 방법이 셋이다. **셋 중 하나만** 보낸다 — 여럿을 보내면 400 이다.

        - file       — PDF 를 직접 올린다(multipart/form-data, 필드명 file)
        - url        — 파일이 있는 https 공인 주소. 받아서 등록까지 하고 이어서 공유를 만든다.
                       받아오지 못하면 400 document_fetch_failed 이고 **공유는 만들어지지 않는다.**
        - documentId — POST /documents 로 미리 등록해 둔 문서

        file · url 로 주면 문서 등록을 따로 부르지 않아 쓰기 호출이 2건에서 1건으로 준다.

        multipart 로 보낼 때는 값이 전부 문자열로 오므로, recipient · sender · options 처럼
        객체인 항목은 **JSON 문자열**로 싣는다.

        멱등키 검사에는 **파일 내용까지 들어간다**. 같은 키 + 같은 파일 = 재시도로 보고 첫 응답을
        재생하고, 같은 키 + 다른 파일 = 409 duplicate_request 다.

        성공 응답에는 고객용 `links.recipient`와 담당자용 `links.host`가 함께 온다.
        API로 생성한 `links.host`는 로그인 없이 열 수 있으며,
        반환된 짧은 주소를 그대로 저장해야 한다. 이전 `#hostAccess=…` 포함 주소도 계속 사용할 수 있다. 주소 소지자는 해당 문서의 호스트로
        입장하므로 CRM 내부에만 보관한다. 일반 웹 공유와 입장권 없는 이전 주소는 생성 계정 로그인이 필요하다.
        재발급 없이 같은 주소를 계속 사용한다. 고객 링크의 기간이 끝나도 담당자 주소는 유지된다.
        공유 회수, 원본 문서 삭제, 세션 종료 후에는 열리지 않는다. 담당자 접근은 고객 열람·다운로드
        횟수와 share.opened·share.downloaded 웹훅에 포함되지 않는다.

        - recipient 는 **필수**다. 생략하거나 null 이면 400 recipient_required.
          받는 사람을 정하지 않는 공개 배포는 이 API 로 만들지 않는다.
        - recipient 는 email 또는 phone 중 하나가 필요하다
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, maxLength: 128 }
          description: 재시도 안전용. 같은 키+같은 본문 = 같은 응답 재생(24h). 다른 본문 = 409.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, recipient]
              properties:
                file: { type: string, format: binary, description: PDF 만 지원. url·documentId와 함께 보내지 않는다 }
                recipient:
                  type: string
                  description: metadata를 포함한 수신자 객체를 JSON 문자열로 싣는다
                  example: '{"name":"김민수","email":"minsu@example.com","phone":"010-1234-5678","externalRef":"cus_1001","metadata":{"contractId":"c-99","branch":"강남","staffId":"emp-042"}}'
                sender:
                  type: string
                  description: 표시용 담당자 객체를 JSON 문자열로 싣는다
                  example: '{"name":"박영업","email":"yh.park@company.com"}'
                options:
                  type: string
                  description: 공유 옵션 객체를 JSON 문자열로 싣는다
                  example: '{"expiresInDays":90,"personalNote":true,"branding":true,"watermark":false,"allowDownload":true,"screenGuard":false}'
                message:
                  type: string
                  description: |
                    고객 화면 안내 문구. {{ }} 로 수신자·발신자 정보를 끼워 넣으면 채워서 보여 준다.
                    쓸 수 있는 값: recipient.name · recipient.email · recipient.phone ·
                    recipient.externalRef · sender.name · sender.email
                    목록에 없는 이름이거나 그 값을 보내지 않았으면 400 invalid_message_template 이고,
                    이때 공유는 만들어지지 않는다.
                  example: '{{recipient.name}}님, 요청하신 계약서를 보내드립니다. — {{sender.name}} 드림'
          application/json:
            schema:
              type: object
              required: [recipient]
              oneOf:
                - required: [documentId]
                - required: [url]
              properties:
                documentId:
                  type: string
                  description: POST /documents로 받은 값. url·file과 함께 보내지 않는다
                  example: doc_pJbchjs_mQ4QYKrkjTDkiA
                url:
                  type: string
                  description: 파일이 있는 https 공인 주소(S3 객체·presigned URL). documentId·file과 함께 보내지 않는다
                  example: https://버킷.s3.ap-northeast-2.amazonaws.com/docs/계약서.pdf?X-Amz-Signature=…
                name:
                  type: string
                  description: url 과 함께 쓰는 저장할 파일명. 생략하면 주소 경로에서 뽑는다
                  example: 계약서.pdf
                sender:
                  type: object
                  nullable: true
                  description: 고객 화면에 표시할 담당자. 생략하면 docs 계정 이름을 쓴다
                  properties:
                    name: { type: string, example: 박영업 }
                    email: { type: string, example: yh.park@company.com }
                recipient:
                  type: object
                  properties:
                    name: { type: string, example: 김민수 }
                    email: { type: string, example: minsu@example.com }
                    phone: { type: string, nullable: true }
                    externalRef: { type: string, description: 호출자 쪽 고객 id — 저장 후 그대로 반환만 한다, example: cus_1001 }
                    metadata:
                      type: object
                      nullable: true
                      additionalProperties: { type: string }
                      description: |
                        호출자가 키 이름을 정해 매달아 두는 값. 저장 후 웹훅(data.metadata)과
                        GET /shares/{id} 에 그대로 반환만 한다. 검색은 되지 않는다.
                        키 50개 · 키 이름 40자(A-Z a-z 0-9 _ -) · 값 500자 문자열.
                        중첩 객체는 거절한다. 어기면 400 invalid_metadata 이고 details.key 에
                        어느 키인지 담기며, 이때 공유는 만들어지지 않는다.
                      example: { contractId: c-99, branch: 강남 }
                options:
                  type: object
                  properties:
                    expiresInDays: { type: integer, enum: [1, 7, 90, 365], description: "GET /me 의 allowedExpiresInDays 에 있는 값이어야 한다. 기본 = min(90, 플랜상한) — Free 1 · Plus 7 · Pro/Business 90" }
                    personalNote: { type: boolean, default: true, description: 수신자가 문서 위에 자기 펜으로 그릴 수 있는지 (계정 저장 아님) }
                    branding: { type: boolean, default: true, description: 계정 기본 브랜드 테마 표시. 기본 테마가 없으면 미표시 }
                    watermark: { type: boolean, default: false }
                    allowDownload: { type: boolean, default: true }
                    screenGuard: { type: boolean, default: false, description: 화면 이탈 보호. Business 전용. 켜면 다운로드가 강제 차단된다 }
                message: { type: string, maxLength: 1000, description: 뷰어 랜딩에 표시. 발송은 호출자 몫 }
            examples:
              url:
                summary: URL의 PDF로 공유 생성
                value:
                  url: https://버킷.s3.ap-northeast-2.amazonaws.com/docs/계약서.pdf?X-Amz-Signature=…
                  name: 계약서.pdf
                  sender: { name: 박영업, email: yh.park@company.com }
                  recipient:
                    name: 김민수
                    email: minsu@example.com
                    phone: 010-1234-5678
                    externalRef: cus_1001
                    metadata: { contractId: c-99, branch: 강남, staffId: emp-042 }
                  options: { expiresInDays: 90, personalNote: true, branding: true, watermark: false, allowDownload: true, screenGuard: false }
                  message: '{{recipient.name}}님, 요청하신 계약서를 보내드립니다. — {{sender.name}}'
              documentId:
                summary: 등록된 문서로 공유 생성
                value:
                  documentId: doc_pJbchjs_mQ4QYKrkjTDkiA
                  sender: { name: 박영업, email: yh.park@company.com }
                  recipient:
                    name: 김민수
                    email: minsu@example.com
                    phone: 010-1234-5678
                    externalRef: cus_1001
                    metadata: { contractId: c-99, branch: 강남, staffId: emp-042 }
                  options: { expiresInDays: 90, personalNote: true, branding: true, watermark: false, allowDownload: true, screenGuard: false }
                  message: '{{recipient.name}}님, 요청하신 계약서를 보내드립니다. — {{sender.name}}'
      responses:
        '201':
          content:
            application/json:
              schema:
                type: object
                properties:
                  links: { $ref: '#/components/schemas/ShareLinks' }
                  warnings:
                    type: array
                    description: 보조 정보 저장에 실패했을 때만 포함된다. 링크와 공유 id는 정상 생성된 상태다.
                    items:
                      type: object
                      required: [code, message]
                      properties:
                        code: { type: string, const: reference_not_stored }
                        message: { type: string }
                additionalProperties: true
              example:
                id: shr_fc1JjpkaDRRXwyKBG08uFg
                recipient: { name: 김민수, externalRef: cus_1001 }
                links:
                  recipient: https://doc.throw.im/s/r/uO3xbWI4nQM8
                  # 예시 입장권은 실제로 사용할 수 없다. 실제 응답의 전체 주소를 저장한다.
                  host: https://doc.throw.im/s/h/AaaaaAAAAA12xxxxxxxxxxxxxxxxxxxxxx
                  # 폐기 예정. 저장하거나 전달하지 말고 recipient 링크를 쓴다.
                  joinCode: SFFCU
                # policy = 실제로 강제되는 값. screenGuard 를 켜면 download 는 여기서 false 로 나간다.
                policy: { expiresInDays: 90, download: true, watermark: false, screenGuard: false, personalNote: true, branding: true }
                # request = 보낸 내용 그대로. 인식하지 못한 옵션 키는 여기서 빠지므로 오타를 응답만으로 잡을 수 있다.
                # options 를 아예 안 보냈으면 null — 기본값이 적용된 것과 구별된다.
                request:
                  options: { expiresInDays: 90 }
                  message: 요청하신 계약서입니다.
                  recipient: { name: 김민수, email: minsu@example.com, phone: null, externalRef: cus_1001 }
                # adjustments = 요청과 적용이 갈린 항목만. 빈 배열이면 보낸 대로 다 반영됐다는 뜻.
                # reason 은 둘뿐이다: no_default_theme (branding) · screen_guard (download)
                adjustments: []
                # document.id 는 file·url 로 보냈을 때 여기서 처음 받는다 — 같은 파일 재발송에 재사용한다.
                document: { id: doc_pJbchjs_mQ4QYKrkjTDkiA, name: 표준계약서.pdf, sizeBytes: 148231 }
                createdAt: '2026-08-27T00:00:00Z'
                expiresAt: '2026-11-25T00:00:00Z'
        '400': { description: idempotency_key_required · recipient_unreachable · bad_request(Invalid documentId) }
        '403': { description: plan_feature_not_available · retention_exceeds_plan_limit · missing_scope }
        '409': { description: duplicate_request · request_in_progress · share_recipient_limit_exceeded }
        '429': { $ref: '#/components/responses/RateLimited' }

  /shares/{id}:
    parameters:
      - { $ref: '#/components/parameters/CrmSenderId' }
    get:
      summary: 공유 상태·열람 요약·링크
      description: |
        POST /shares 응답을 놓쳤다면(타임아웃·크래시) 고객 링크와 담당자 링크를 여기서 복구한다.

        단, share id 자체를 잃으면 목록·externalRef 검색 API가 없어 조회할 수 없다.
        24시간 안이고 Idempotency-Key 를 보관했다면 같은 키와 같은 요청을 재시도해
        POST /shares 응답을 재생할 수 있다. 이후에는 복구할 수 없으므로 id와 키를 함께 저장한다.
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  links:
                    oneOf:
                      - { $ref: '#/components/schemas/ShareLinks' }
                      - { type: 'null' }
                additionalProperties: true
              example:
                id: shr_fc1JjpkaDRRXwyKBG08uFg
                status: { session: WAITING, link: active }
                # 공유를 만들 때 보낸 externalRef. 연동처가 이 값으로 자기 DB 에서 고객을 찾는다.
                externalRef: cus_1001
                # 명시적으로 회수한 공유는 links 가 null 이다.
                links:
                  recipient: https://doc.throw.im/s/r/uO3xbWI4nQM8
                  host: https://doc.throw.im/s/h/AaaaaAAAAA12
                  # 폐기 예정. 저장하거나 전달하지 말고 recipient 링크를 쓴다.
                  joinCode: SFFCU
                expiresAt: '2026-10-26T00:00:00Z'
                opened: { count: 3, lastAt: '2026-08-27T10:00:00Z' }
                downloads: { count: 1 }
        '404': { description: 남의 공유·없는 공유 모두 404 — 존재 여부를 알려주지 않는다 }
    delete:
      summary: 링크 회수
      description: 세션 링크와 모든 수신자 링크를 즉시 무효화한다.
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200': { content: { application/json: { example: { ok: true, revokedAt: '2026-08-27T00:00:00Z' } } } }
        '404': { description: Share not found }

webhooks:
  shareEvent:
    post:
      summary: 공유 열람·다운로드·만료 알림
      description: POST /webhooks 로 등록한 공개 HTTPS 주소로 전송한다.
      parameters:
        - name: X-Throw-Event
          in: header
          required: true
          schema: { $ref: '#/components/schemas/WebhookEventName' }
        - name: X-Throw-Delivery
          in: header
          required: true
          schema: { type: string, example: dlv_90 }
          description: 재시도 중복 제거에 사용하는 전송 ID
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookDeliveryEvent' }
      responses:
        '200':
          description: 10초 안에 2xx로 응답한다. 2xx가 아니면 동일한 X-Throw-Delivery로 재시도된다.

components:
  parameters:
    CrmSenderId:
      name: X-CRM-Sender-Id
      in: header
      required: false
      description: |
        해당 요청의 CRM 담당자 통계용 선택 식별자. 인증 정보가 아니며 sender.name/email과 별개다.
        수집 가능 형식은 영문·숫자·밑줄·하이픈 1~128자(대소문자 구분)다.
        미전달·빈 값·중복 값·형식 오류·토큰 미식별은 NULL로 처리하며 이 헤더 때문에 요청을 거절하지 않는다.
        개인정보·토큰을 보내지 않는다. 같은 계정·토큰 안에서만 구분하며 응답과 멱등성 판정은 바뀌지 않는다.
        수집기와 로그 테이블이 배포된 이후 요청부터 적용된다.
      schema:
        type: string
      example: crm_Staff-001
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer, bearerFormat: 'tdk_live_…' }
  schemas:
    ShareLinks:
      type: object
      required: [recipient, host]
      properties:
        recipient:
          type: string
          format: uri
          description: 고객에게 전달하고 저장할 공유 링크
        host:
          type: [string, 'null']
          format: uri
          description: API 생성 공유는 입장권을 포함한 전체 주소로 로그인 없이 입장한다. CRM 내부에서만 보관하고 반복 사용한다. 일반 웹 공유 또는 API 생성 이력이 없는 이전 공유는 생성 계정 로그인이 필요하다.
        joinCode:
          type: [string, 'null']
          deprecated: true
          description: 폐기 예정. 저장하거나 전달하지 말고 recipient 를 사용한다.

    WebhookEventName:
      type: string
      enum: [share.opened, share.downloaded, share.expired, share.memo_created]

    WebhookRegisterRequest:
      type: object
      required: [name, url]
      properties:
        name: { type: string, minLength: 1, maxLength: 100, example: CRM 알림 }
        url: { type: string, format: uri, maxLength: 2048, example: https://crm.example.com/hooks/throw }
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: { $ref: '#/components/schemas/WebhookEventName' }
          description: 생략하면 네 이벤트를 모두 받는다.

    WebhookRegistration:
      type: object
      required: [id, name, url, events, status, createdAt]
      properties:
        id: { type: string, pattern: '^whk_', example: whk_Fh7daz1oQZoxm61cuCHIVg }
        name: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEventName' }
        status: { type: string, const: active }
        createdAt: { type: string, format: date-time }

    WebhookListItem:
      type: object
      required: [id, name, url, events, status, consecutiveFailures, lastDeliveredAt, createdAt]
      properties:
        id: { type: string, pattern: '^whk_' }
        name: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEventName' }
        status: { type: string, enum: [active, disabled] }
        consecutiveFailures: { type: integer, minimum: 0 }
        lastDeliveredAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }

    WebhookRequestSnapshot:
      type: object
      required: [method, url, headers, body]
      properties:
        method: { type: string, const: POST }
        url: { type: string, format: uri }
        headers:
          type: object
          required: [Content-Type, X-Throw-Event, X-Throw-Delivery]
          properties:
            Content-Type: { type: string, const: application/json }
            X-Throw-Event: { type: string }
            X-Throw-Delivery: { type: string }
        body: { type: object, additionalProperties: true }

    WebhookDeliveryInspection:
      type: object
      required: [deliveryId, event, status, attemptCount, request, response, nextAttemptAt, deliveredAt, createdAt]
      properties:
        deliveryId: { type: string, example: dlv_91 }
        event: { $ref: '#/components/schemas/WebhookEventName' }
        status: { type: string, enum: [pending, delivered, failed, exhausted] }
        attemptCount: { type: integer, minimum: 0 }
        request: { $ref: '#/components/schemas/WebhookRequestSnapshot' }
        response:
          type: object
          required: [statusCode, error]
          properties:
            statusCode: { type: [integer, 'null'] }
            error: { type: [string, 'null'] }
        nextAttemptAt: { type: [string, 'null'], format: date-time }
        deliveredAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }

    WebhookDeliveryHistory:
      type: object
      required: [webhook, deliveries]
      properties:
        webhook:
          type: object
          required: [id, name, url]
          properties:
            id: { type: string, pattern: '^whk_' }
            name: { type: string }
            url: { type: string, format: uri }
        deliveries:
          type: array
          items: { $ref: '#/components/schemas/WebhookDeliveryInspection' }

    WebhookTestEvent:
      type: object
      required: [event, webhookId, createdAt, data]
      properties:
        event: { type: string, const: webhook.test }
        webhookId: { type: string, pattern: '^whk_' }
        createdAt: { type: string, format: date-time }
        data:
          type: object
          required: [test]
          properties:
            test: { type: boolean, const: true }

    WebhookTestResult:
      type: object
      required: [deliveryId, request, response]
      properties:
        deliveryId: { type: string, pattern: '^dlv_test_' }
        request:
          allOf:
            - { $ref: '#/components/schemas/WebhookRequestSnapshot' }
            - type: object
              properties:
                body: { $ref: '#/components/schemas/WebhookTestEvent' }
        response:
          type: object
          required: [ok, statusCode, durationMs, error]
          properties:
            ok: { type: boolean }
            statusCode: { type: [integer, 'null'] }
            durationMs: { type: integer, minimum: 0 }
            error: { type: [string, 'null'] }

    WebhookViewer:
      type: [object, 'null']
      required: [name]
      properties:
        name: { type: string, description: 인증되지 않은 표시 이름 }

    WebhookSender:
      type: [object, 'null']
      required: [name, email]
      properties:
        name: { type: [string, 'null'] }
        email: { type: [string, 'null'] }
      description: 공유 생성 시 받은 표시용 담당자. 이름과 이메일이 모두 없으면 null.

    WebhookRecipientEventData:
      type: object
      required: [shareId, externalRef, metadata, viewer, sender]
      properties:
        links:
          type: [object, 'null']
          description: share.opened에만 추가되는 링크. 기존 구독 그대로 전달한다. 참가자가 확인되면 해당 고객 링크, 아니면 공개 게스트 링크를 반환한다. 사용할 수 없는 주소는 null. host는 고객에게 전달하지 않는다.
          required: [host, recipient]
          properties:
            host: { type: [string, 'null'], format: uri }
            recipient: { type: [string, 'null'], format: uri }
        shareId: { type: string, pattern: '^shr_' }
        externalRef:
          type: [string, 'null']
          description: 고객 링크로 처리된 경우의 고객 식별자. 다른 경로면 null.
        metadata:
          type: [object, 'null']
          additionalProperties: { type: string }
          description: 고객 링크로 처리된 경우 recipient.metadata. 다른 경로면 null.
        viewer: { $ref: '#/components/schemas/WebhookViewer' }
        sender: { $ref: '#/components/schemas/WebhookSender' }

    WebhookMemo:
      type: object
      required: [id, page, visibility]
      properties:
        id: { type: string }
        page: { type: [integer, 'null'], minimum: 1 }
        visibility: { type: string, enum: [all, private, selected] }
        text:
          type: string
          description: 전체 공개(visibility가 all)일 때만 포함됩니다.

    WebhookMemoCreatedEventData:
      allOf:
        - { $ref: '#/components/schemas/WebhookRecipientEventData' }
        - type: object
          required: [memo]
          properties:
            memo: { $ref: '#/components/schemas/WebhookMemo' }

    WebhookExpiredEventData:
      type: object
      required: [shareId, externalRef, metadata, viewer, sender]
      properties:
        shareId: { type: string, pattern: '^shr_' }
        externalRef: { type: 'null', description: 만료는 특정 열람자 이벤트가 아니므로 항상 null. }
        metadata: { type: 'null', description: 만료는 특정 참여자 이벤트가 아니므로 항상 null. }
        viewer: { type: 'null', description: 만료는 열람 이벤트가 아니므로 항상 null. }
        sender: { $ref: '#/components/schemas/WebhookSender' }

    WebhookOpenedEvent:
      type: object
      required: [event, webhookId, createdAt, data]
      properties:
        event: { type: string, const: share.opened }
        webhookId: { type: string, pattern: '^whk_' }
        createdAt: { type: string, format: date-time }
        data: { $ref: '#/components/schemas/WebhookRecipientEventData' }
      example:
        event: share.opened
        webhookId: whk_TAGr3xSZ2NpXeB4YSBqabQ
        createdAt: '2026-08-27T10:00:00Z'
        data:
          links:
            host: https://doc.throw.im/s/h/AaaaaAAAAA12xxxxxxxxxxxxxxxxxxxxxx
            recipient: https://doc.throw.im/s/r/uO3xbWI4nQM8
          shareId: shr_1EuEW9BtUXqT4mLpKzR8Vw
          externalRef: cus_9911
          metadata: { contractId: c-99, branch: 강남 }
          viewer: { name: 김민수 }
          sender: { name: 박영업, email: yh.park@example.com }

    WebhookDownloadedEvent:
      type: object
      required: [event, webhookId, createdAt, data]
      properties:
        event: { type: string, const: share.downloaded }
        webhookId: { type: string, pattern: '^whk_' }
        createdAt: { type: string, format: date-time }
        data: { $ref: '#/components/schemas/WebhookRecipientEventData' }
      example:
        event: share.downloaded
        webhookId: whk_TAGr3xSZ2NpXeB4YSBqabQ
        createdAt: '2026-08-27T10:03:00Z'
        data:
          shareId: shr_1EuEW9BtUXqT4mLpKzR8Vw
          externalRef: cus_9911
          metadata: { contractId: c-99, branch: 강남 }
          viewer: { name: 김민수 }
          sender: { name: 박영업, email: yh.park@example.com }

    WebhookExpiredEvent:
      type: object
      required: [event, webhookId, createdAt, data]
      properties:
        event: { type: string, const: share.expired }
        webhookId: { type: string, pattern: '^whk_' }
        createdAt: { type: string, format: date-time }
        data: { $ref: '#/components/schemas/WebhookExpiredEventData' }
      example:
        event: share.expired
        webhookId: whk_TAGr3xSZ2NpXeB4YSBqabQ
        createdAt: '2026-11-25T00:00:00Z'
        data:
          shareId: shr_1EuEW9BtUXqT4mLpKzR8Vw
          externalRef: null
          metadata: null
          viewer: null
          sender: { name: 박영업, email: yh.park@example.com }

    WebhookMemoCreatedEvent:
      type: object
      required: [event, webhookId, createdAt, data]
      properties:
        event: { type: string, const: share.memo_created }
        webhookId: { type: string, pattern: '^whk_' }
        createdAt: { type: string, format: date-time }
        data: { $ref: '#/components/schemas/WebhookMemoCreatedEventData' }
      example:
        event: share.memo_created
        webhookId: whk_TAGr3xSZ2NpXeB4YSBqabQ
        createdAt: '2026-08-27T10:05:00Z'
        data:
          shareId: shr_1EuEW9BtUXqT4mLpKzR8Vw
          externalRef: cus_9911
          metadata: { contractId: c-99, branch: 강남 }
          viewer: { name: 김민수 }
          sender: { name: 박영업, email: yh.park@example.com }
          memo: { id: memo_01, page: 2, visibility: all, text: 확인 부탁드립니다. }

    WebhookDeliveryEvent:
      oneOf:
        - { $ref: '#/components/schemas/WebhookOpenedEvent' }
        - { $ref: '#/components/schemas/WebhookDownloadedEvent' }
        - { $ref: '#/components/schemas/WebhookExpiredEvent' }
        - { $ref: '#/components/schemas/WebhookMemoCreatedEvent' }
      discriminator:
        propertyName: event
        mapping:
          share.opened: '#/components/schemas/WebhookOpenedEvent'
          share.downloaded: '#/components/schemas/WebhookDownloadedEvent'
          share.expired: '#/components/schemas/WebhookExpiredEvent'
          share.memo_created: '#/components/schemas/WebhookMemoCreatedEvent'
  responses:
    Unauthorized:
      description: invalid_token · token_revoked
      content:
        application/json:
          example: { error: { code: invalid_token, message: Invalid API token } }
    RateLimited:
      description: 토큰당 쓰기 5/분 · 읽기 60/분. X-RateLimit-Limit/-Remaining/-Reset 헤더 참조.
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          example: { error: { code: rate_limited, message: Rate limit exceeded — retry later, details: { retryAfterSeconds: 60 } } }
