openapi: 3.1.0
info:
  title: RankAutomatic Publish API
  version: "1"
  description: |
    The contract a custom-built site implements so RankAutomatic can publish blog articles to it.
    Every request is signed. The signature is hex(HMAC_SHA256(secret, "{timestamp}.{METHOD}.{path}.{raw_body}")), sent as
    `X-RankAutomatic-Signature: v1=<hex>` with `X-RankAutomatic-Timestamp` (unix seconds). METHOD is the HTTP method in
    uppercase; path is the request path exactly as sent, without scheme, host or query string (for example
    /api/rankautomatic/articles/ra-article-12). Requests whose timestamp is more than 300 seconds from the server's
    clock are rejected. For GET and DELETE the raw body is the empty string.
    Test vector: secret "s", timestamp 1700000000, method GET, path /api/rankautomatic/health, empty body gives
    v1=c0d3ffa65bd9db7c9cedf1265fab66d9175d635f84100ec45d3baf7087addfba.
    A post saved with status "draft" must never be publicly visible.
servers:
  - url: https://example.com/api/rankautomatic
    description: Your endpoint URL, as pasted into RankAutomatic.
security:
  - rankautomaticSignature: []
paths:
  /health:
    get:
      operationId: getHealth
      summary: Report the API version
      responses:
        "200":
          description: The endpoint is up and speaks v1.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /articles/{external_id}:
    parameters:
      - $ref: "#/components/parameters/ExternalId"
      - $ref: "#/components/parameters/Timestamp"
      - $ref: "#/components/parameters/Signature"
      - $ref: "#/components/parameters/RequestId"
    put:
      operationId: putArticle
      summary: Create or update an article (upsert on external_id)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ArticleInput"
      responses:
        "200":
          description: Saved. `status` is "published" only when the post is publicly visible.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SaveResponse"
        "400":
          $ref: "#/components/responses/Invalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "500":
          $ref: "#/components/responses/ServerError"
    get:
      operationId: getArticle
      summary: Read back what the site stored
      responses:
        "200":
          description: The stored article.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StoredArticle"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteArticle
      summary: Remove an article
      responses:
        "204":
          description: Removed.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
components:
  securitySchemes:
    rankautomaticSignature:
      type: apiKey
      in: header
      name: X-RankAutomatic-Signature
      description: |
        "v1=" followed by the lowercase hex HMAC-SHA256 of "{timestamp}.{METHOD}.{path}.{raw_body}" using the
        shared secret (METHOD uppercase, path as sent without scheme, host or query string).
        Always sent together with X-RankAutomatic-Timestamp. Compare in constant time.
  parameters:
    ExternalId:
      name: external_id
      in: path
      required: true
      description: The app sends "ra-article-{id}". The connection test uses "ra-connection-test".
      schema:
        type: string
    Timestamp:
      name: X-RankAutomatic-Timestamp
      in: header
      required: true
      description: Unix seconds when the request was signed. Reject if more than 300 seconds from now.
      schema:
        type: string
        pattern: "^[0-9]+$"
    Signature:
      name: X-RankAutomatic-Signature
      in: header
      required: true
      schema:
        type: string
        pattern: "^v1=[0-9a-f]{64}$"
    RequestId:
      name: X-RankAutomatic-Request-Id
      in: header
      required: false
      description: A UUID for logging. Not part of the signature.
      schema:
        type: string
        format: uuid
  schemas:
    Health:
      type: object
      required: [ok, version, capabilities]
      properties:
        ok:
          type: boolean
          const: true
        version:
          type: string
          const: "1"
        capabilities:
          type: array
          items:
            type: string
            enum: [articles, faqs]
          description: >-
            "articles" is the base. "faqs" means the page renders the PUT's faqs itself,
            so the FAQ section is left out of body_html and body_md.
    ArticleInput:
      type: object
      required: [external_id, title, slug, body_html, status]
      properties:
        external_id:
          type: string
          description: Must equal the path parameter.
        title:
          type: string
        slug:
          type: string
        body_html:
          type: string
          description: >-
            The article body as HTML. Render it inside the post's main content. It has no H1:
            render the title as the page's H1.
        body_md:
          type: string
          description: The same body as Markdown, without a leading H1.
        meta_description:
          type: string
          description: Render in <meta name="description">.
        quick_answer:
          type: ["string", "null"]
          description: A 40-60 word answer-first summary of the article, plain sentences. Null for older articles.
        faqs:
          type: array
          description: >-
            The article's FAQ, [] when it has none. With the "faqs" capability the body
            leaves its FAQ section out; schema_json carries the FAQPage either way.
          items:
            type: object
            required: [question, answer]
            properties:
              question:
                type: string
              answer:
                type: string
        focus_keyword:
          type: string
        featured_image_url:
          type: ["string", "null"]
          format: uri
        featured_image_alt:
          type: ["string", "null"]
          description: Render as the alt text of the featured image.
        categories:
          type: array
          description: The article's topic as one entry, or [] when it has none.
          items:
            type: string
        tags:
          type: array
          maxItems: 5
          items:
            type: string
            maxLength: 40
        schema_json:
          type: string
          description: A JSON-LD document as a string. Render it inside <script type="application/ld+json">.
        author:
          type: ["object", "null"]
          required: [name]
          properties:
            name:
              type: string
            bio:
              type: string
        published_at:
          type: ["string", "null"]
          format: date-time
        status:
          type: string
          enum: [draft, publish]
          description: A "draft" must never be publicly visible. Only "publish" makes the post public.
    SaveResponse:
      type: object
      required: [external_id, url, status]
      properties:
        external_id:
          type: string
        url:
          type: string
          format: uri
          description: The post's absolute public URL (also for a draft).
        status:
          type: string
          enum: [draft, published]
    StoredArticle:
      type: object
      required: [external_id, url, status, title, slug, updated_at]
      properties:
        external_id:
          type: string
        url:
          type: string
          format: uri
        status:
          type: string
          enum: [draft, published]
        title:
          type: string
        slug:
          type: string
        updated_at:
          type: string
          format: date-time
          description: ISO 8601 with a timezone offset. Must change only when the article itself changes (title, body, meta description, status), not on views or cache rebuilds.
        meta_description:
          type: string
          nullable: true
          description: Optional. The stored meta description; omit or null when the site keeps none.
    Error:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          enum: [unauthorized, invalid, conflict, not_found, error]
        message:
          type: string
  responses:
    Unauthorized:
      description: Missing, stale or wrong signature.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: unauthorized
            message: The request signature is missing or wrong.
    Invalid:
      description: The body is not valid.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: The article cannot be saved in its current state (for example, the slug belongs to another post).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: No article with that external_id.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: not_found
            message: No article with that external_id.
    ServerError:
      description: The site failed to process the request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
