openapi: 3.0.3
info:
  title: Harbormaster API
  description: |
    **Sanctum v1** — automation with `Authorization: Bearer <personal access token>` (create tokens under **API tokens** when signed in).

    **Browser session API** — same JSON shapes as v1; use from the logged-in app (session cookie + CSRF on mutating web routes). `GET` capture endpoints do not require CSRF.

    **Webhook ingress** — send traffic to `POST` (or any method) `/w/{publicId}` to record a capture; no API token. Response status and body come from the bin’s ingress settings.

    Capture JSON may apply per-bin **display transforms** (masked fields, renamed headers) in API responses.
  version: 1.0.0
  contact:
    name: Harbormaster
servers:
  - url: /
    description: Same origin as this document (replace in “Try it” with your deployed URL if needed)

tags:
  - name: Sanctum v1
    description: Bearer token API (`/api/v1/...`)
  - name: Session API
    description: Browser session (`/api/bins/...`)
  - name: Webhook ingress
    description: Public ingest URL (no auth)

paths:
  /api/v1/bins:
    get:
      tags: [Sanctum v1]
      summary: List bins
      description: Returns personal bins (owned by the user) plus bins in any team the user belongs to.
      operationId: v1ListBins
      security:
        - bearerAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [bins]
                properties:
                  bins:
                    type: array
                    items:
                      $ref: '#/components/schemas/BinSummary'
              examples:
                sample:
                  value:
                    bins:
                      - public_id: 01ARZ3NDEKTSV4RRFFQ69G5FAV
                        label: Production
                        webhook_url: https://example.com/w/01ARZ3NDEKTSV4RRFFQ69G5FAV
                        forward_url: null
                        response_status: 204
                        team: null
                        created_at: '2026-01-15T12:00:00+00:00'
        '401':
          $ref: '#/components/responses/Unauthorized'
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            curl -sS -H "Authorization: Bearer YOUR_TOKEN" \
              "${APP_URL}/api/v1/bins"
        - lang: javascript
          label: JavaScript (fetch)
          source: |
            const res = await fetch(`${APP_URL}/api/v1/bins`, {
              headers: { Authorization: `Bearer ${process.env.WEBHOOK_DEBUGGER_TOKEN}` },
            });
            const data = await res.json();
        - lang: python
          label: Python
          source: |
            import os, requests
            r = requests.get(
                f"{os.environ['APP_URL']}/api/v1/bins",
                headers={"Authorization": f"Bearer {os.environ['WEBHOOK_DEBUGGER_TOKEN']}"},
            )
            r.raise_for_status()
            print(r.json())

  /api/v1/bins/{publicId}/captures:
    get:
      tags: [Sanctum v1]
      summary: List captures
      description: |
        Newest first, max 200. Optional `since_id` returns only captures with `id` greater than the value (polling).
      operationId: v1ListCaptures
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PublicId'
        - name: since_id
          in: query
          description: Return captures with numeric id strictly greater than this value.
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [captures]
                properties:
                  captures:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookCapture'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            curl -sS -H "Authorization: Bearer YOUR_TOKEN" \
              "${APP_URL}/api/v1/bins/01ARZ3NDEKTSV4RRFFQ69G5FAV/captures?since_id=0"

  /api/v1/bins/{publicId}/captures/{captureId}:
    get:
      tags: [Sanctum v1]
      summary: Get one capture
      operationId: v1GetCapture
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PublicId'
        - name: captureId
          in: path
          required: true
          description: Numeric capture primary key.
          schema:
            type: integer
            example: 42
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCapture'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            curl -sS -H "Authorization: Bearer YOUR_TOKEN" \
              "${APP_URL}/api/v1/bins/01ARZ3NDEKTSV4RRFFQ69G5FAV/captures/42"

  /api/bins/{publicId}/captures:
    get:
      tags: [Session API]
      summary: List captures (session)
      description: Same behavior as v1; requires a logged-in browser session (or cookie) with permission to view the bin.
      operationId: sessionListCaptures
      security:
        - sessionCookie: []
      parameters:
        - $ref: '#/components/parameters/PublicId'
        - name: since_id
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [captures]
                properties:
                  captures:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookCapture'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
      x-codeSamples:
        - lang: curl
          label: cURL (session cookie)
          source: |
            # Export cookies from your browser or use -b cookie.txt after logging in via the web UI.
            curl -sS -b "laravel-session=YOUR_SESSION_VALUE" \
              "${APP_URL}/api/bins/01ARZ3NDEKTSV4RRFFQ69G5FAV/captures"

  /api/bins/{publicId}/captures/{captureId}:
    get:
      tags: [Session API]
      summary: Get one capture (session)
      operationId: sessionGetCapture
      security:
        - sessionCookie: []
      parameters:
        - $ref: '#/components/parameters/PublicId'
        - name: captureId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCapture'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/bins/{publicId}/stream:
    get:
      tags: [Session API]
      summary: Live capture IDs (SSE)
      description: |
        Server-Sent Events (`text/event-stream`). Emits `event: capture` with `data: {"id":123}` for new captures.
        Query `last_id` (default 0) to skip older IDs. Connection ends after ~120s; reconnect with updated `last_id`.
      operationId: sessionCaptureStream
      security:
        - sessionCookie: []
      parameters:
        - $ref: '#/components/parameters/PublicId'
        - name: last_id
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: SSE stream
          content:
            text/event-stream:
              schema:
                type: string
                example: |
                  retry: 2000

                  event: capture
                  data: {"id":42}

        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
      x-codeSamples:
        - lang: javascript
          label: JavaScript (EventSource)
          source: |
            const es = new EventSource(
              `${APP_URL}/api/bins/01ARZ3NDEKTSV4RRFFQ69G5FAV/stream?last_id=0`,
              { withCredentials: true }
            );
            es.addEventListener('capture', (e) => console.log(JSON.parse(e.data)));

  # Ingress signed-HMAC / custom-host copy was trimmed from the operation description while those docs are disabled in the app.
  /w/{publicId}:
    post:
      tags: [Webhook ingress]
      summary: Record a webhook (ingress)
      description: |
        **All HTTP methods** are accepted (GET, POST, PUT, PATCH, DELETE, etc.); OpenAPI lists `POST` as the primary example.

        Records method, full URL, query, headers, body (max 512 KiB), and client IP. Then returns the bin’s configured status and body (often **204 No Content**).
      operationId: webhookIngressPost
      parameters:
        - $ref: '#/components/parameters/PublicId'
      requestBody:
        description: Optional body (JSON, form, raw bytes).
        content:
          application/json:
            schema:
              type: object
          text/plain:
            schema:
              type: string
      responses:
        '204':
          description: No content (default when bin has empty body and status 204)
        '200':
          description: Custom status/body from bin settings
        '401':
          description: Unauthorized (e.g. signed ingest required but signature missing, invalid, or timestamp skew too large)
        '404':
          description: Unknown bin id
        '413':
          description: Body exceeds 512 KiB
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            curl -sS -X POST "${APP_URL}/w/01ARZ3NDEKTSV4RRFFQ69G5FAV" \
              -H "Content-Type: application/json" \
              -d '{"hello":"world"}'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Personal access token from **API tokens** (Sanctum).
    sessionCookie:
      type: apiKey
      in: cookie
      name: laravel-session
      description: |
        Laravel session cookie from the web app. Default name is the slug of APP_NAME plus `-session` (see `config/session.php` and `SESSION_COOKIE` in `.env`).
        For automation, prefer **Bearer** tokens on `/api/v1`.

  parameters:
    PublicId:
      name: publicId
      in: path
      required: true
      description: Bin public id (26-character ULID).
      schema:
        type: string
        pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'
        example: 01ARZ3NDEKTSV4RRFFQ69G5FAV

  responses:
    Unauthorized:
      description: Missing or invalid Bearer token
    Forbidden:
      description: Authenticated but not allowed to view this bin
    NotFound:
      description: Bin or capture not found

  schemas:
    BinSummary:
      type: object
      required: [public_id, webhook_url, response_status]
      properties:
        public_id:
          type: string
        label:
          type: string
          nullable: true
        webhook_url:
          type: string
          format: uri
        forward_url:
          type: string
          nullable: true
          format: uri
        response_status:
          type: integer
        team:
          type: object
          nullable: true
          description: Present when the bin belongs to a team workspace.
          properties:
            id:
              type: integer
            name:
              type: string
        created_at:
          type: string
          format: date-time
          nullable: true

    WebhookCapture:
      type: object
      required: [id, method, url, headers, body_bytes]
      properties:
        id:
          type: integer
        method:
          type: string
          example: POST
        url:
          type: string
          format: uri
        query:
          type: object
          nullable: true
          additionalProperties: true
        headers:
          type: object
          additionalProperties: true
        body:
          type: string
          nullable: true
        body_bytes:
          type: integer
        content_type:
          type: string
          nullable: true
        ip_address:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
