openapi: 3.1.0
info:
  title: UDI Lens Connect
  version: "2026-10-01"
  summary: 將 UDI Lens App 的掃描結果即時轉送到你的系統
  description: |
    UDI Lens Connect 會把 App 掃描到的 UDI 資料，以 HTTPS `POST` 送到你指定的 URL。

    ## 你的端點必須滿足

    | 項目 | 要求 |
    |---|---|
    | 傳輸 | 僅 `https://`、TLS 1.2 以上、公開 CA 簽發的有效憑證、port 443 |
    | 驗證 | 以 API Key 驗證 `webhook-signature`（HMAC-SHA256，常數時間比較） |
    | 時間 | `webhook-timestamp` 與目前時間相差超過 5 分鐘須拒收（防重放） |
    | 冪等 | 以 `webhook-id` 去重；同一事件可能送達不只一次（at-least-once） |
    | 回應 | 10 秒內回 `2xx`；其他狀態碼、逾時、重新導向皆視為失敗 |
    | 相容 | 忽略不認得的欄位與事件類型（新增欄位不視為破壞性變更） |
    | 註冊 | 收到 `endpoint.verification` 時回 `200` 與 `{"challenge": "<原值>"}` |

    ## 簽章（Standard Webhooks）

    採用 [Standard Webhooks](https://www.standardwebhooks.com) 規範，可直接使用其官方函式庫。

    ```
    signed_content = "${webhook-id}.${webhook-timestamp}.${原始 body}"
    key            = base64_decode(API Key 去掉 "whsec_" 前綴)
    signature      = "v1," + base64(HMAC-SHA256(key, signed_content))
    ```

    `webhook-signature` 可能含多組以空白分隔的簽章（API Key 輪替的 24 小時內新舊並行），任一組相符即為有效。
    **API Key 不會出現在請求中**，請勿要求我方以標頭傳送。

    ## 重試

    非 `2xx` 時依序於 1 分、5 分、30 分、2 小時、6 小時、12 小時後重試（共 7 次，約 21 小時）。
    端點連續失敗 3 天會自動停用，須在 App 或入口網站重新驗證。
    同一端點的事件**不保證順序**，請以 `data.scanned_at` 排序。

    ## 版本

    `api_version` 為日期字串。破壞性變更會發布新版本並提前公告；同版本內只會新增欄位。
  contact:
    name: UDI Lens 支援
    url: https://udilens.app/support
servers:
  - url: https://api.udilens.app
    description: App 與入口網站使用的 Relay API（客戶端點不需呼叫）

webhooks:
  scan.created:
    post:
      summary: 新掃描
      description: App 使用者完成一次掃描（單筆或批次中的一筆）。
      operationId: scanCreated
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ScanCreatedEvent"
            example:
              type: scan.created
              api_version: "2026-10-01"
              id: evt_01JB8Z3K9Q4C7XR2M5N6P8T0VW
              created_at: "2026-10-02T08:00:01.123Z"
              data:
                scan_id: 5f0c2c1e-3b0a-4c39-9d0c-2f1d8f6b7a11
                scanned_at: "2026-10-02T07:59:58+08:00"
                issuer: GS1
                format: gs1_element_string
                raw: "]d2010081234567890117270531"
                di: "00812345678901"
                lot: A123
                serial: S0001
                expiry: "2027-05-31"
                production_date: null
                gudid:
                  status: found
                  brand_name: Sample Device
                  company_name: Sample Medical Inc.
                  model_number: SD-100
                batch_id: null
                source:
                  org_id: org_8x2k4m9q0r1s3t5v7w
                  member_id: mem_2b4d6f8h0j2k4m6n8p
                  member_label: 3F 開刀房 iPhone 2
      responses:
        "2XX":
          description: 已收到。回應內容會被忽略。
  endpoint.verification:
    post:
      summary: 端點驗證
      description: |
        在 App 或入口網站按下「驗證」時送出。你的端點必須回 `200`，body 為 `{"challenge": "<data.challenge 原值>"}`。
        此事件同樣帶有簽章，建議先完成驗章再回應。
      operationId: endpointVerification
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerificationEvent"
      responses:
        "200":
          description: 回傳 challenge
          content:
            application/json:
              schema:
                type: object
                required: [challenge]
                properties:
                  challenge:
                    type: string
  endpoint.test:
    post:
      summary: 測試事件
      description: 在 App 或入口網站按下「送出測試事件」時送出，`data` 為範例掃描，格式與 `scan.created` 相同。
      operationId: endpointTest
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TestEvent"
      responses:
        "2XX":
          description: 已收到

components:
  parameters:
    WebhookId:
      name: webhook-id
      in: header
      required: true
      description: 事件 ID，與 body 的 `id` 相同；重試時不變，請據此去重。
      schema:
        type: string
        example: evt_01JB8Z3K9Q4C7XR2M5N6P8T0VW
    WebhookTimestamp:
      name: webhook-timestamp
      in: header
      required: true
      description: 送出時間（Unix 秒）；每次重試會更新。
      schema:
        type: string
        example: "1790928001"
    WebhookSignature:
      name: webhook-signature
      in: header
      required: true
      description: 以空白分隔的一或多組 `v1,<base64>` 簽章。
      schema:
        type: string
        example: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

  schemas:
    EventEnvelope:
      type: object
      required: [type, api_version, id, created_at, data]
      properties:
        type:
          type: string
        api_version:
          type: string
          example: "2026-10-01"
        id:
          type: string
          description: 事件 ID（可依字典序排序）
        created_at:
          type: string
          format: date-time

    ScanCreatedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: scan.created
            data:
              $ref: "#/components/schemas/ScanData"

    TestEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: endpoint.test
            data:
              $ref: "#/components/schemas/ScanData"

    VerificationEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type:
              const: endpoint.verification
            data:
              type: object
              required: [challenge]
              properties:
                challenge:
                  type: string

    ScanData:
      type: object
      required: [scan_id, scanned_at, issuer, format, raw, di, lot, serial, expiry, production_date, gudid, batch_id, source]
      properties:
        scan_id:
          type: string
          format: uuid
          description: App 端產生的掃描 ID（同一使用者內唯一）
        scanned_at:
          type: string
          format: date-time
          description: 裝置上的掃描時間（含時區）
        issuer:
          type: string
          enum: [GS1, HIBCC]
        format:
          type: string
          enum: [gs1_element_string, gs1_digital_link, hibcc]
        raw:
          type: string
          maxLength: 1024
          description: 條碼原始內容（GS1 的 FNC1 以 ASCII 29 表示；主次條碼合併時以 `\n` 分隔）
        di:
          type: string
          maxLength: 50
          description: 器材識別碼（GS1 為 14 碼 GTIN；HIBCC 為 LIC + 產品碼 + 計量單位）
        lot:
          type: [string, "null"]
          maxLength: 64
          description: 批號（GS1 AI 10）
        serial:
          type: [string, "null"]
          maxLength: 64
          description: 序號（GS1 AI 21）
        expiry:
          type: [string, "null"]
          format: date
          description: 效期（GS1 AI 17；日為 00 時已換算為當月最後一天）
        production_date:
          type: [string, "null"]
          format: date
          description: 製造日期（GS1 AI 11）
        gudid:
          oneOf:
            - $ref: "#/components/schemas/Gudid"
            - type: "null"
        batch_id:
          type: [string, "null"]
          format: uuid
          description: 批次掃描的批次 ID；單筆掃描為 null
        source:
          $ref: "#/components/schemas/Source"

    Gudid:
      type: object
      description: App 查詢 FDA AccessGUDID 的結果（查詢在裝置端進行）
      required: [status, brand_name, company_name, model_number]
      properties:
        status:
          type: string
          enum: [found, not_found, unknown]
        brand_name:
          type: [string, "null"]
        company_name:
          type: [string, "null"]
        model_number:
          type: [string, "null"]

    Source:
      type: object
      description: 送出此掃描的 App 使用者（不含個資）
      required: [org_id, member_id, member_label]
      properties:
        org_id:
          type: [string, "null"]
          description: 組織 ID；個人模式為 null
        member_id:
          type: string
          description: 送出者代號，不含個資。組織模式為成員代號 `mem_...`；個人模式為訂閱者代號 `sub_...`。請視為不透明字串，勿解析格式。
        member_label:
          type: [string, "null"]
          description: 組織 Admin 自訂的成員代稱，例如裝置位置

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
            message:
              type: string
            detail:
              type: object
