openapi: 3.1.0
info:
  title: POSnavigator Blog API
  version: "1.0.0"
  description: |-
    List, create, read, and patch blog posts over X-Api-Key. New posts
    are always DRAFT and added to the draft category. If English translation is
    omitted, Hungarian content is copied into English fields. Use HTML fragments in
    excerptHtml and contentHtml (p, h2, h3, ul, ol, li, strong, em, a, img).
    
    Call https://posnavigator.eu directly. Legacy .hu redirects can drop authenticated
    write headers.
    
    # The happy path
    
    1. GET /openapi/blogs.yaml
    2. POST /api/v1/blogs with hu title + excerptHtml + contentHtml
    3. GET /api/v1/blogs?status=DRAFT to find the id
    4. PATCH status PUBLISHED when ready
    
    Default keys include blog:read and blog:write. Some runtimes still accept any
    valid key for these routes; send the scopes anyway.
    
    Send `X-Api-Key: <key>` on every operation except this document itself.
    Create a key at /user/api-keys. Format: `pn_` plus a random string. Copy it
    immediately; it is shown once. Default new keys include blog, legal, presetfilter,
    versus-landing, widget, agent-analytics and hero-analytics scopes. Missing scopes
    return 403 INSUFFICIENT_PERMISSIONS. Bank-scoped resources also require the key
    owner to be admin or listed on that bank; otherwise 403 FORBIDDEN.
    
    # Response envelope
    
    Success: `{ "success": true, "data": ... }`.
    Failure: `{ "success": false, "error": { "code", "message" } }`.
    Branch on error.code, never on error.message.
    
    # Error codes
    
    | code | status | what to do |
    | --- | --- | --- |
    | MISSING_API_KEY | 401 | add X-Api-Key; do not retry blindly |
    | INVALID_API_KEY | 401 | stop; ask a human for a new key |
    | INSUFFICIENT_PERMISSIONS | 403 | stop; this key lacks a required scope |
    | FORBIDDEN | 403 | stop or switch to a key with bank/admin access |
    | RATE_LIMIT_EXCEEDED | 429 | wait Retry-After seconds |
    | INTERNAL_ERROR | 500 | retry with backoff |
    
    # Rate limiting
    
    Every response after successful authentication carries X-RateLimit-Limit,
    X-RateLimit-Remaining and X-RateLimit-Reset. A 429 also carries Retry-After.
    The two 401s are emitted before rate-limit accounting. Default window is 100
    requests per rolling hour unless the key was configured otherwise.
    
    # Canonical host
    
    Use https://posnavigator.eu. Legacy .hu hosts redirect; authenticated writes
    should call .eu directly.
    
    Additional codes: INVALID_QUERY 400, INVALID_PAYLOAD 400, NOT_FOUND 404.
servers:
  - url: https://posnavigator.eu
tags:
  - name: Discovery
  - name: Blogs
security:
  - ApiKeyAuth: []
paths:
  /openapi/blogs.yaml:
    get:
      operationId: getBlogsOpenApi
      tags: [Discovery]
      security: []
      summary: Public OpenAPI 3.1 description
      description: |-
        This document. Public: no API key, no rate limit. Fetch it first and treat
        it as the complete manual. Every schema, limit, example, error code and
        header an agent needs is here. Linked from GET /openapi/catalog.yaml.
        Data operations below require authentication as described in info.
      responses:
        '200':
          description: OpenAPI YAML
          content:
            application/yaml:
              schema:
                type: string
              example: 'openapi: 3.1.0'
  /api/v1/blogs:
    get:
      operationId: listBlogs
      tags: [Blogs]
      summary: List blog posts
      description: |-
        Paginated list. Optional status DRAFT|PUBLISHED|SCHEDULED, limit 1-100
        default 20, offset default 0. List items include excerpts, not full
        contentHtml. Use getBlog for the full post.
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [DRAFT, PUBLISHED, SCHEDULED] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: Blog list
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlogListResponse'
        '400':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
    post:
      operationId: createBlog
      tags: [Blogs]
      summary: Create a draft blog post
      description: |-
        Always created as DRAFT in the draft category. Required: author and
        translations.hu with title, excerptHtml, contentHtml. slug is optional
        (generated from title). Optional English translations; if omitted, HU is
        copied. Optional categoryIds, tagIds, relatedBlogIds, presetFilterIds,
        embeddedWidgetId, priority, scheduledFor.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BlogCreateRequest'
            example:
              author: POSnavigator API
              translations:
                hu:
                  title: Mi alapján válassz POS terminált 2026-ban?
                  excerptHtml: <p>Rövid összefoglaló.</p>
                  contentHtml: <h2>Bevezetés</h2><p>Első bekezdés.</p>
      responses:
        '201':
          description: Created draft
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlogDetailResponse'
        '400':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /api/v1/blogs/{blogId}:
    parameters:
      - name: blogId
        in: path
        required: true
        schema: { type: string, pattern: '^[a-fA-F0-9]{24}$', maxLength: 24 }
    get:
      operationId: getBlog
      tags: [Blogs]
      summary: Get one blog post including HTML body
      description: |-
        Full post including contentHtml for both locales. 404 when the id does
        not exist. Use this before PATCH so you do not drop HTML you did not
        intend to change.
      responses:
        '200':
          description: Blog post
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlogDetailResponse'
        '400':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
    patch:
      operationId: patchBlog
      tags: [Blogs]
      summary: Partially update a blog post
      description: |-
        Partial update. translations.hu/en merge. status may be DRAFT, PUBLISHED,
        or SCHEDULED. Publishing a post without English copy still keeps the
        HU-copied English fields unless you send translations.en. Do not send
        empty translations objects unless you mean to clear optional fields the
        validator allows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BlogPatchRequest'
            example:
              status: PUBLISHED
              translations:
                hu:
                  title: Frissített cím
                  contentHtml: <h2>Végleges</h2><p>Publikált tartalom.</p>
      responses:
        '200':
          description: Updated post
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlogDetailResponse'
        '400':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '422':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |-
        Send `X-Api-Key: pn_...` on every data operation. Create a key at
        /user/api-keys; it is shown once. Inactive or expired keys return 401.
  headers:
    X-RateLimit-Limit:
      description: Maximum requests allowed in the current window
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Requests remaining in the current window
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix epoch seconds when the window resets
      schema:
        type: integer
    Retry-After:
      description: Seconds to wait before retrying a 429
      schema:
        type: integer
  parameters:
    BankId:
      name: bankId
      in: path
      required: true
      description: 24-character hex Mongo ObjectId of the bank
      schema:
        type: string
        pattern: '^[a-fA-F0-9]{24}$'
        maxLength: 24

  schemas:
    ErrorEnvelope:
      type: object
      additionalProperties: false
      required: [success, error]
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code:
              type: string
              maxLength: 80
              description: Stable machine code. Branch on this, never on message.
            message:
              type: string
              maxLength: 2000
            details:
              type: array
              maxItems: 50
              items:
                type: string
                maxLength: 500
            retryAfter:
              type: integer
              description: Present on RATE_LIMIT_EXCEEDED
            field_errors:
              type: object
              additionalProperties:
                type: array
                maxItems: 20
                items:
                  type: string
                  maxLength: 500
    LocalePair:
      type: object
      additionalProperties: false
      properties:
        hu:
          type: string
          maxLength: 20000
        en:
          type: string
          maxLength: 20000
    OffsetPagination:
      type: object
      additionalProperties: false
      required: [total, limit, offset, hasMore]
      properties:
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        hasMore:
          type: boolean
    BlogListResponse:
      type: object
      required: [success, data]
      additionalProperties: false
      properties:
        success: { type: boolean, const: true }
        data:
          type: object
          additionalProperties: false
          required: [items, pagination]
          properties:
            items:
              type: array
              maxItems: 100
              items: { type: object, additionalProperties: true }
            pagination:
              $ref: '#/components/schemas/OffsetPagination'
    BlogDetailResponse:
      type: object
      required: [success, data]
      additionalProperties: false
      properties:
        success: { type: boolean, const: true }
        data:
          type: object
          additionalProperties: true
    BlogLocaleInput:
      type: object
      additionalProperties: true
      properties:
        title: { type: string, maxLength: 300, description: Post title }
        slug: { type: string, maxLength: 200, description: Optional; generated from title if omitted }
        excerptHtml: { type: string, maxLength: 20000, description: HTML excerpt }
        contentHtml: { type: string, maxLength: 200000, description: HTML body }
    BlogCreateRequest:
      type: object
      required: [author, translations]
      additionalProperties: true
      properties:
        author: { type: string, maxLength: 200, description: Byline shown on the post }
        translations:
          type: object
          required: [hu]
          additionalProperties: false
          properties:
            hu: { $ref: '#/components/schemas/BlogLocaleInput' }
            en: { $ref: '#/components/schemas/BlogLocaleInput' }
        status:
          type: string
          enum: [DRAFT, PUBLISHED, SCHEDULED]
          description: Ignored on create; server forces DRAFT
        priority: { type: integer }
        categoryIds:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 24 }
        tagIds:
          type: array
          maxItems: 40
          items: { type: string, maxLength: 24 }
        presetFilterIds:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 24 }
        embeddedWidgetId:
          type: [string, 'null']
          maxLength: 24
    BlogPatchRequest:
      type: object
      additionalProperties: true
      properties:
        author: { type: string, maxLength: 200 }
        status:
          type: string
          enum: [DRAFT, PUBLISHED, SCHEDULED]
          description: Set PUBLISHED when the post should go live
        translations:
          type: object
          additionalProperties: false
          properties:
            hu: { $ref: '#/components/schemas/BlogLocaleInput' }
            en: { $ref: '#/components/schemas/BlogLocaleInput' }
        priority: { type: integer }
        categoryIds:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 24 }
        tagIds:
          type: array
          maxItems: 40
          items: { type: string, maxLength: 24 }

