{
  "openapi": "3.1.0",
  "info": {
    "title": "Just Ship Now API",
    "version": "1.1.0",
    "description": "Just Ship Now (justship.now) is a curated directory of tools for developers, indie hackers, and startup builders, plus a build-in-public blog. This is a read-only public surface: there is no authentication and no paid tier. Every content page (home, about, contact, privacy, blog posts) supports content negotiation - request it with `Accept: text/markdown` to get a clean markdown body instead of HTML, ideal for an agent that just wants the text. Use GET /llms.txt for a short directory index, or GET /llms-full.txt for the full tool directory and blog archive in one document.\n\n**Versioning**: REST endpoints under `/api/v1/...` are versioned in the URL path. A breaking change ships as `/api/v2/...` alongside the still-working `/v1` surface, never as a silent change to `/v1` itself; `info.version` above tracks the spec's own revision, independent of the path version. An endpoint moved to a new version keeps its old path working and marks the old operation `deprecated: true` with a `Deprecation: true` response header and a `Link: <new-url>; rel=\"successor-version\"` header pointing at its replacement - see `/api/social/instagram-card/{slug}` below for the one path that's gone through this so far. No removal date is set on a deprecated path unless a `Sunset` header is present.\n\n**Rate limits**: every `/api/v1/...` and `/api/social/...` endpoint returns standard `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` response headers (IETF RateLimit header fields), and a `429` with `Retry-After` once the per-minute limit for that client is exceeded. Content pages and the machine-readable files (llms.txt, sitemap.xml, robots.txt) are not rate-limited.",
    "contact": {
      "name": "Alex Cloudstar",
      "url": "https://justship.now/contact"
    }
  },
  "servers": [
    {
      "url": "https://justship.now"
    }
  ],
  "paths": {
    "/": {
      "get": {
        "operationId": "getHomepage",
        "summary": "Homepage: directory overview",
        "description": "The tools directory homepage - section and category overview, tool count, newsletter signup. Supports markdown content negotiation.",
        "parameters": [
          {
            "name": "Accept",
            "in": "header",
            "required": false,
            "description": "text/markdown (optionally weighted, e.g. `text/markdown;q=1.0, text/html;q=0.9`) returns a markdown version of this same page instead of HTML. Defaults to text/html.",
            "schema": {
              "type": "string",
              "enum": [
                "text/html",
                "text/markdown"
              ],
              "default": "text/html"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The page, as HTML or markdown depending on the Accept header.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              },
              "text/markdown": {
                "schema": {
                  "type": "string"
                },
                "example": "# Just Ship Now\n\n> A curated directory of tools..."
              }
            }
          }
        }
      }
    },
    "/about": {
      "get": {
        "operationId": "getAboutPage",
        "summary": "About page: who runs Just Ship Now",
        "description": "Author bio, track record, and Product Hunt history. Supports markdown content negotiation.",
        "parameters": [
          {
            "name": "Accept",
            "in": "header",
            "required": false,
            "description": "text/markdown (optionally weighted, e.g. `text/markdown;q=1.0, text/html;q=0.9`) returns a markdown version of this same page instead of HTML. Defaults to text/html.",
            "schema": {
              "type": "string",
              "enum": [
                "text/html",
                "text/markdown"
              ],
              "default": "text/html"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The page, as HTML or markdown depending on the Accept header.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              },
              "text/markdown": {
                "schema": {
                  "type": "string"
                },
                "example": "# About\n\nJust Ship Now is made by..."
              }
            }
          }
        }
      }
    },
    "/contact": {
      "get": {
        "operationId": "getContactPage",
        "summary": "Contact page: how to reach the author",
        "description": "Real contact channels (X, newsletter reply). No contact form. Supports markdown content negotiation.",
        "parameters": [
          {
            "name": "Accept",
            "in": "header",
            "required": false,
            "description": "text/markdown (optionally weighted, e.g. `text/markdown;q=1.0, text/html;q=0.9`) returns a markdown version of this same page instead of HTML. Defaults to text/html.",
            "schema": {
              "type": "string",
              "enum": [
                "text/html",
                "text/markdown"
              ],
              "default": "text/html"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The page, as HTML or markdown depending on the Accept header.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              },
              "text/markdown": {
                "schema": {
                  "type": "string"
                },
                "example": "# Contact\n\n..."
              }
            }
          }
        }
      }
    },
    "/privacy": {
      "get": {
        "operationId": "getPrivacyPage",
        "summary": "Privacy policy",
        "description": "What data is collected and how it's used. Supports markdown content negotiation.",
        "parameters": [
          {
            "name": "Accept",
            "in": "header",
            "required": false,
            "description": "text/markdown (optionally weighted, e.g. `text/markdown;q=1.0, text/html;q=0.9`) returns a markdown version of this same page instead of HTML. Defaults to text/html.",
            "schema": {
              "type": "string",
              "enum": [
                "text/html",
                "text/markdown"
              ],
              "default": "text/html"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The page, as HTML or markdown depending on the Accept header.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              },
              "text/markdown": {
                "schema": {
                  "type": "string"
                },
                "example": "# Privacy\n\n..."
              }
            }
          }
        }
      }
    },
    "/blog/{slug}": {
      "get": {
        "operationId": "getBlogPost",
        "summary": "A single blog post",
        "description": "Returns one build-in-public blog post by slug. Supports markdown content negotiation.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The post's URL slug, as listed in GET /llms.txt or GET /sitemap.xml.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "Accept",
            "in": "header",
            "required": false,
            "description": "text/markdown (optionally weighted, e.g. `text/markdown;q=1.0, text/html;q=0.9`) returns a markdown version of this same page instead of HTML. Defaults to text/html.",
            "schema": {
              "type": "string",
              "enum": [
                "text/html",
                "text/markdown"
              ],
              "default": "text/html"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The page, as HTML or markdown depending on the Accept header.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              },
              "text/markdown": {
                "schema": {
                  "type": "string"
                },
                "example": "# Post title\n\n..."
              }
            }
          },
          "404": {
            "description": "No post exists for this slug.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/llms.txt": {
      "get": {
        "operationId": "getLlmsIndex",
        "summary": "Short agent-readable site index",
        "description": "A concise, agent-focused index of the site: author, directory sections/categories with tool counts, and recent blog posts. Follows the llmstxt.org convention.",
        "responses": {
          "200": {
            "description": "Plain-text markdown index.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/llms-full.txt": {
      "get": {
        "operationId": "getLlmsFullIndex",
        "summary": "Full agent-readable site dump",
        "description": "Every tool in the directory (name, description, category, live URL) and the full text of every blog post, in one document - for an agent that wants the whole corpus in a single request instead of crawling page by page.",
        "responses": {
          "200": {
            "description": "Plain-text markdown dump.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/sitemap.xml": {
      "get": {
        "operationId": "getSitemap",
        "summary": "XML sitemap",
        "description": "Standard XML sitemap of every public page on the site.",
        "responses": {
          "200": {
            "description": "XML sitemap.",
            "content": {
              "application/xml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/robots.txt": {
      "get": {
        "operationId": "getRobotsTxt",
        "summary": "Robots exclusion file",
        "description": "Standard robots.txt. Disallows /dashboard (an authenticated, non-public area); everything else is crawlable.",
        "responses": {
          "200": {
            "description": "Plain-text robots rules.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/tools": {
      "get": {
        "operationId": "listTools",
        "summary": "List every tool in the directory",
        "description": "Returns every curated tool as typed JSON - the same data behind the HTML directory and llms-full.txt, for a caller that wants to query it programmatically. Optionally filtered to one category.",
        "parameters": [
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "A category slug (see GET /llms.txt for the current list) to filter to just that category's tools.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The matching tools.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ToolList"
                }
              }
            }
          },
          "404": {
            "description": "The given category slug doesn't exist.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded for this client in the current one-minute window.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "Retry-After": {
                "$ref": "#/components/headers/Retry-After"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/tools/{slug}": {
      "get": {
        "operationId": "getTool",
        "summary": "Get one tool by slug",
        "description": "Returns a single tool's data as typed JSON.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The tool's URL slug, as listed in GET /api/v1/tools or GET /llms-full.txt.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The matching tool.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tool"
                }
              }
            }
          },
          "404": {
            "description": "No tool exists for this slug.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded for this client in the current one-minute window.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "Retry-After": {
                "$ref": "#/components/headers/Retry-After"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/categories": {
      "get": {
        "operationId": "listCategories",
        "summary": "List every tool category",
        "description": "Returns every tool category as typed JSON - the valid values for /api/v1/tools's ?category= filter.",
        "responses": {
          "200": {
            "description": "Every category.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CategoryList"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded for this client in the current one-minute window.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "Retry-After": {
                "$ref": "#/components/headers/Retry-After"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/posts": {
      "get": {
        "operationId": "listPosts",
        "summary": "List every blog post",
        "description": "Returns every build-in-public blog post's metadata (not full body) as typed JSON - a structured alternative to /blog for a caller that wants fielded data (date, category, read time) instead of parsing HTML or markdown.",
        "responses": {
          "200": {
            "description": "Every post's metadata.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PostList"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded for this client in the current one-minute window.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "Retry-After": {
                "$ref": "#/components/headers/Retry-After"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/posts/{slug}": {
      "get": {
        "operationId": "getPostJson",
        "summary": "Get one blog post by slug, as JSON",
        "description": "Returns a single post's metadata and full markdown body as typed JSON - the same content as GET /blog/{slug} with `Accept: text/markdown`, fielded instead of flattened into one markdown document.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The post's URL slug, as listed in GET /api/v1/posts or GET /llms.txt.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The matching post.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PostDetail"
                }
              }
            }
          },
          "404": {
            "description": "No post exists for this slug.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded for this client in the current one-minute window.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "Retry-After": {
                "$ref": "#/components/headers/Retry-After"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/social/instagram-card/{slug}": {
      "get": {
        "operationId": "getBlogPostShareImageV1",
        "summary": "Square share image for a blog post",
        "description": "Generates a 1080x1080 PNG share image for one blog post, for cross-posting to Instagram/social. Public, unauthenticated, cacheable. Canonical, versioned home for this endpoint.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The post's URL slug, as listed in GET /llms.txt.",
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9-]+$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "1080x1080 PNG image.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "image/png": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "The slug is malformed, or no post exists for it.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded for this client in the current one-minute window.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "Retry-After": {
                "$ref": "#/components/headers/Retry-After"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/social/instagram-card/{slug}": {
      "get": {
        "operationId": "getBlogPostShareImage",
        "summary": "Square share image for a blog post (deprecated alias)",
        "description": "Deprecated alias for GET /api/v1/social/instagram-card/{slug} - kept working indefinitely for existing callers, but no longer the canonical path. Every response carries `Deprecation: true` and `Link: <.../api/v1/social/instagram-card/{slug}>; rel=\"successor-version\"`.",
        "deprecated": true,
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The post's URL slug, as listed in GET /llms.txt.",
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9-]+$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "1080x1080 PNG image.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "Deprecation": {
                "$ref": "#/components/headers/Deprecation"
              },
              "Link": {
                "$ref": "#/components/headers/Link"
              }
            },
            "content": {
              "image/png": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "The slug is malformed, or no post exists for it.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "Deprecation": {
                "$ref": "#/components/headers/Deprecation"
              },
              "Link": {
                "$ref": "#/components/headers/Link"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded for this client in the current one-minute window.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              },
              "Retry-After": {
                "$ref": "#/components/headers/Retry-After"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable, machine-readable error code."
              },
              "message": {
                "type": "string",
                "description": "Human-readable explanation of what went wrong."
              },
              "hint": {
                "type": "string",
                "description": "Optional pointer to how to recover, e.g. a related URL."
              }
            }
          }
        }
      },
      "Tool": {
        "type": "object",
        "required": [
          "slug",
          "name",
          "description",
          "category",
          "url"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "description": "URL slug, e.g. as used in /tools/{slug}."
          },
          "name": {
            "type": "string",
            "description": "The tool's display name."
          },
          "description": {
            "type": "string",
            "description": "One-line description of what the tool does."
          },
          "category": {
            "type": "string",
            "description": "Category slug, e.g. as used in /categories/{slug}."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The tool's own live site."
          }
        }
      },
      "ToolList": {
        "type": "object",
        "required": [
          "tools",
          "count"
        ],
        "properties": {
          "tools": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Tool"
            }
          },
          "count": {
            "type": "integer",
            "description": "Number of tools in this response (after any category filter)."
          }
        }
      },
      "Category": {
        "type": "object",
        "required": [
          "slug",
          "name",
          "section",
          "description"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "description": "Category slug - the valid value for /api/v1/tools's ?category= filter."
          },
          "name": {
            "type": "string",
            "description": "The category's display name."
          },
          "section": {
            "type": "string",
            "description": "The higher-level grouping this category belongs to."
          },
          "description": {
            "type": "string",
            "description": "One-line description of what belongs in this category."
          }
        }
      },
      "CategoryList": {
        "type": "object",
        "required": [
          "categories",
          "count"
        ],
        "properties": {
          "categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Category"
            }
          },
          "count": {
            "type": "integer",
            "description": "Number of categories in this response."
          }
        }
      },
      "Post": {
        "type": "object",
        "required": [
          "slug",
          "title",
          "excerpt",
          "date",
          "readTime",
          "category",
          "url"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "description": "URL slug, e.g. as used in /blog/{slug}."
          },
          "title": {
            "type": "string",
            "description": "The post's title."
          },
          "excerpt": {
            "type": "string",
            "description": "One-line summary of the post."
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Publish date, YYYY-MM-DD."
          },
          "readTime": {
            "type": "integer",
            "description": "Estimated reading time in minutes."
          },
          "category": {
            "type": "string",
            "description": "Blog category slug."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Canonical URL of the post on justship.now."
          }
        }
      },
      "PostDetail": {
        "type": "object",
        "required": [
          "slug",
          "title",
          "excerpt",
          "date",
          "readTime",
          "category",
          "url",
          "content"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "description": "URL slug, e.g. as used in /blog/{slug}."
          },
          "title": {
            "type": "string",
            "description": "The post's title."
          },
          "excerpt": {
            "type": "string",
            "description": "One-line summary of the post."
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Publish date, YYYY-MM-DD."
          },
          "readTime": {
            "type": "integer",
            "description": "Estimated reading time in minutes."
          },
          "category": {
            "type": "string",
            "description": "Blog category slug."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Canonical URL of the post on justship.now."
          },
          "content": {
            "type": "string",
            "description": "The post's full body, as markdown."
          }
        }
      },
      "PostList": {
        "type": "object",
        "required": [
          "posts",
          "count"
        ],
        "properties": {
          "posts": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Post"
            }
          },
          "count": {
            "type": "integer",
            "description": "Number of posts in this response."
          }
        }
      }
    },
    "headers": {
      "RateLimit-Limit": {
        "description": "Requests allowed per one-minute window.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Remaining": {
        "description": "Requests remaining in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Reset": {
        "description": "Seconds until the current window resets.",
        "schema": {
          "type": "integer"
        }
      },
      "Retry-After": {
        "description": "Seconds to wait before retrying (429 responses only).",
        "schema": {
          "type": "integer"
        }
      },
      "Deprecation": {
        "description": "Present and set to \"true\" on a deprecated endpoint.",
        "schema": {
          "type": "string",
          "enum": [
            "true"
          ]
        }
      },
      "Link": {
        "description": "Points at the successor endpoint, e.g. `<https://justship.now/api/v1/...>; rel=\"successor-version\"`.",
        "schema": {
          "type": "string"
        }
      }
    }
  }
}