openapi: 3.1.0
info:
  title: Blogtrottr API
  version: '1.0'
  description: |
    REST API for managing RSS/Atom feed subscriptions, categories, and delivery addresses.

    All requests must include `Accept: application/json` and an `Authorization: Bearer {token}` header.
    Manage API tokens from your account settings.

servers:
  - url: 'https://blogtrottr.com/api'

security:
  - bearerAuth: []

tags:
  - name: Subscriptions
  - name: Categories
  - name: Email Addresses

paths:

  /subscriptions:

    get:
      summary: List subscriptions
      operationId: listSubscriptions
      tags: [Subscriptions]
      description: Returns a paginated list of subscriptions. Requires `read` ability.
      parameters:
        - name: url
          in: query
          schema:
            type: string
            format: uri
          description: Filter by exact feed URL.
        - name: enabled
          in: query
          schema:
            type: boolean
          description: Filter by enabled state.
        - name: schedule
          in: query
          schema:
            type: string
            enum: [asap, 2h_digest, 4h_digest, 6h_digest, 8h_digest, 12h_digest, daily_digest]
          description: Filter by schedule type.
        - name: output
          in: query
          schema:
            type: string
            enum: [email, attach_pdf, attach_html, attach_text, webhook]
          description: Filter by output type.
        - name: category_id
          in: query
          schema:
            type: [string, 'null']
          description: Filter by category ID. Pass null to list uncategorised subscriptions.
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
          description: Page number.
      responses:
        '200':
          description: Paginated list of subscriptions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedSubscriptions'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      summary: Create subscription
      operationId: createSubscription
      tags: [Subscriptions]
      description: |
        Creates a new subscription. The feed URL is discovered and normalised — if it resolves
        or redirects to a different URL, the final URL is used. Requires `create` ability.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSubscriptionRequest'
      responses:
        '201':
          description: Subscription created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Subscription'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /subscriptions/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Subscription ID.

    get:
      summary: Get subscription
      operationId: getSubscription
      tags: [Subscriptions]
      description: Returns a single subscription. Requires `read` ability.
      responses:
        '200':
          description: Subscription object.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Subscription'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      summary: Update subscription
      operationId: updateSubscription
      tags: [Subscriptions]
      description: Partially updates a subscription. Only include fields to change. Requires `update` ability.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateSubscriptionRequest'
      responses:
        '200':
          description: Updated subscription.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Subscription'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      summary: Delete subscription
      operationId: deleteSubscription
      tags: [Subscriptions]
      description: Permanently deletes a subscription. Requires `delete` ability.
      responses:
        '204':
          description: Deleted successfully.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /categories:

    get:
      summary: List categories
      operationId: listCategories
      tags: [Categories]
      description: Returns a paginated list of categories ordered by name. Requires `read` ability.
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
          description: Page number.
      responses:
        '200':
          description: Paginated list of categories.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedCategories'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

    post:
      summary: Create category
      operationId: createCategory
      tags: [Categories]
      description: Creates a new category. Requires `create` ability.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCategoryRequest'
      responses:
        '201':
          description: Category created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Category'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /categories/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Category ID.

    get:
      summary: Get category
      operationId: getCategory
      tags: [Categories]
      description: Returns a single category. Requires `read` ability.
      responses:
        '200':
          description: Category object.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Category'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      summary: Update category
      operationId: updateCategory
      tags: [Categories]
      description: Partially updates a category. Only include fields to change. Requires `update` ability.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCategoryRequest'
      responses:
        '200':
          description: Updated category.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Category'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      summary: Delete category
      operationId: deleteCategory
      tags: [Categories]
      description: |
        Permanently deletes a category. Any subscriptions assigned to this category will have
        their category_id set to null. Requires `delete` ability.
      responses:
        '204':
          description: Deleted successfully.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /email-addresses:

    get:
      summary: List email addresses
      operationId: listEmailAddresses
      tags: [Email Addresses]
      description: |
        Returns a paginated list of additional delivery email addresses on the account.
        Use the `id` field as `email_address_id` when creating or updating subscriptions.
        Requires `read` ability.
      parameters:
        - name: email
          in: query
          schema:
            type: string
            format: email
          description: Filter by exact email address (case-insensitive).
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
          description: Page number.
      responses:
        '200':
          description: Paginated list of email addresses.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedEmailAddresses'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'

  /email-addresses/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Email address ID.

    get:
      summary: Get email address
      operationId: getEmailAddress
      tags: [Email Addresses]
      description: Returns a single email address. Requires `read` ability.
      responses:
        '200':
          description: Email address object.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/EmailAddress'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Sanctum API token. Manage tokens from your account settings.

  responses:
    Unauthenticated:
      description: Missing or invalid token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorMessage'
    Forbidden:
      description: Token lacks the required ability for this endpoint.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorMessage'
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorMessage'
    ValidationError:
      description: Validation error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationError'

  schemas:

    Subscription:
      type: object
      properties:
        id:
          type: string
        enabled:
          type: boolean
        url:
          type: string
          format: uri
        schedule:
          type: string
          enum: [asap, 2h_digest, 4h_digest, 6h_digest, 8h_digest, 12h_digest, daily_digest]
        output:
          type: string
          enum: [email, attach_pdf, attach_html, attach_text, webhook]
        digest_sort:
          type: [string, 'null']
          enum: [newest_first, oldest_first, title_az, title_za, null]
          description: null when schedule is asap.
        from:
          type: [string, 'null']
        subject_format:
          type: [string, 'null']
        title_only:
          type: boolean
        user_title:
          type: [string, 'null']
        category_id:
          type: [string, 'null']
        email_address_id:
          type: [string, 'null']
        filter:
          oneOf:
            - $ref: '#/components/schemas/FilterObject'
            - type: 'null'
        created_at:
          type: [string, 'null']
          format: date-time
        updated_at:
          type: [string, 'null']
          format: date-time

    Category:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        filter:
          oneOf:
            - $ref: '#/components/schemas/FilterObject'
            - type: 'null'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    EmailAddress:
      type: object
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time

    FilterObject:
      type: object
      required: [version, enabled, action, group_operator, groups]
      additionalProperties: false
      properties:
        version:
          type: integer
          const: 2
        enabled:
          type: boolean
        action:
          type: string
          enum: [permit, deny]
          description: permit — deliver only matching items. deny — skip matching items.
        group_operator:
          type: string
          enum: [and, or]
          description: How to combine multiple groups.
        groups:
          type: array
          maxItems: 5
          items:
            $ref: '#/components/schemas/FilterGroup'

    FilterGroup:
      type: object
      required: [operator, rules]
      additionalProperties: false
      properties:
        operator:
          type: string
          enum: [and, or]
          description: How to combine rules within this group.
        rules:
          type: array
          minItems: 1
          maxItems: 250
          items:
            $ref: '#/components/schemas/FilterRule'

    FilterRule:
      type: object
      required: [field, operator, value]
      additionalProperties: false
      properties:
        field:
          type: string
          enum: [item_title, item_body, item_url, author, category]
        operator:
          type: string
          enum: [contains, not_contains, regex, not_regex]
        value:
          type: string
          minLength: 1
          maxLength: 500
          pattern: '\S'
          description: |
            Match value. Must contain at least one non-whitespace character.
            For regex and not_regex operators: a PCRE pattern without delimiters,
            matched case-insensitively. Max 200 characters for regex patterns.
            Total rules across all groups may not exceed 500.

    WebhookHeader:
      type: object
      required: [key, value]
      additionalProperties: false
      properties:
        key:
          type: string
          maxLength: 255
        value:
          type: string
          maxLength: 1024

    CreateSubscriptionRequest:
      type: object
      required: [url, schedule, output]
      properties:
        url:
          type: string
          format: uri
          maxLength: 1000
          description: Feed URL. Must be unique per account. Will be normalised via feed discovery.
        schedule:
          type: string
          enum: [asap, 2h_digest, 4h_digest, 6h_digest, 8h_digest, 12h_digest, daily_digest]
        output:
          type: string
          enum: [email, attach_pdf, attach_html, attach_text, webhook]
        digest_sort:
          type: string
          enum: [newest_first, oldest_first, title_az, title_za]
          default: newest_first
        from:
          type: [string, 'null']
          maxLength: 150
          description: Custom sender name.
        subject_format:
          type: [string, 'null']
          maxLength: 150
          description: Custom email subject template.
        utitle:
          type: [string, 'null']
          maxLength: 150
          description: Custom subscription title.
        titleonly:
          type: [boolean, 'null']
          description: Send item title only, no body.
        email_address_id:
          type: [string, 'null']
          description: ID of an additional delivery email address.
        category_id:
          type: [string, 'null']
          description: ID of a category to assign this subscription to.
        filter:
          $ref: '#/components/schemas/FilterObject'
        webhook_url:
          type: string
          format: uri
          maxLength: 2048
          description: Required when output is webhook. Must be a publicly reachable URL.
        webhook_headers:
          type: [array, 'null']
          maxItems: 10
          items:
            $ref: '#/components/schemas/WebhookHeader'
        webhook_content_type:
          type: string
          enum: [application/json, application/x-www-form-urlencoded, text/plain]
          default: application/json
        webhook_body_template:
          type: [string, 'null']
          maxLength: 10000
          description: Required for non-JSON webhooks.

    UpdateSubscriptionRequest:
      type: object
      description: All fields are optional. Only include fields you want to change.
      properties:
        enabled:
          type: boolean
        schedule:
          type: string
          enum: [asap, 2h_digest, 4h_digest, 6h_digest, 8h_digest, 12h_digest, daily_digest]
        output:
          type: string
          enum: [email, attach_pdf, attach_html, attach_text, webhook]
        digest_sort:
          type: string
          enum: [newest_first, oldest_first, title_az, title_za]
        from:
          type: [string, 'null']
          maxLength: 150
        subject_format:
          type: [string, 'null']
          maxLength: 150
        utitle:
          type: [string, 'null']
          maxLength: 150
        titleonly:
          type: [boolean, 'null']
        email_address_id:
          type: [string, 'null']
        category_id:
          type: [string, 'null']
          description: Pass null to remove from a category.
        filter:
          $ref: '#/components/schemas/FilterObject'
        webhook_url:
          type: [string, 'null']
          format: uri
          maxLength: 2048
        webhook_headers:
          type: [array, 'null']
          maxItems: 10
          items:
            $ref: '#/components/schemas/WebhookHeader'
        webhook_content_type:
          type: [string, 'null']
          enum: [application/json, application/x-www-form-urlencoded, text/plain, null]
        webhook_body_template:
          type: [string, 'null']
          maxLength: 10000

    CreateCategoryRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 80
          description: Must be unique per account (case-insensitive).
        filter:
          oneOf:
            - $ref: '#/components/schemas/FilterObject'
            - type: 'null'

    UpdateCategoryRequest:
      type: object
      description: All fields are optional. Only include fields you want to change.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 80
          description: Must be unique per account (case-insensitive).
        filter:
          oneOf:
            - $ref: '#/components/schemas/FilterObject'
            - type: 'null'

    PaginatedSubscriptions:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Subscription'
        links:
          $ref: '#/components/schemas/PaginationLinks'
        meta:
          $ref: '#/components/schemas/PaginationMeta'

    PaginatedCategories:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Category'
        links:
          $ref: '#/components/schemas/PaginationLinks'
        meta:
          $ref: '#/components/schemas/PaginationMeta'

    PaginatedEmailAddresses:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/EmailAddress'
        links:
          $ref: '#/components/schemas/PaginationLinks'
        meta:
          $ref: '#/components/schemas/PaginationMeta'

    PaginationLinks:
      type: object
      properties:
        first:
          type: [string, 'null']
          format: uri
        last:
          type: [string, 'null']
          format: uri
        prev:
          type: [string, 'null']
          format: uri
        next:
          type: [string, 'null']
          format: uri

    PaginationMeta:
      type: object
      properties:
        current_page:
          type: integer
        last_page:
          type: integer
        per_page:
          type: integer
        total:
          type: integer

    ErrorMessage:
      type: object
      properties:
        message:
          type: string

    ValidationError:
      type: object
      properties:
        message:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
