{
  "openapi": "3.1.0",
  "info": {
    "title": "ShipReal API",
    "version": "1.0.0",
    "summary": "Read-only access to the course curriculum and pricing.",
    "description": "Everything ShipReal knows about itself, as JSON: search the curriculum,\nread a module, read current pricing.\n\nNo authentication, no accounts, no keys, no rate limit. It is public reference\ndata about a course, served from the edge. There is deliberately no write path:\nbuying is a hosted checkout, so an agent should hand the buyer a link rather\nthan attempt a purchase.\n\nThe same three capabilities are available over MCP at https://shipreal.dev/mcp, and as a\nnatural-language endpoint at https://shipreal.dev/ask.",
    "contact": {
      "name": "ShipReal",
      "email": "michael@shipreal.dev",
      "url": "https://shipreal.dev/contact"
    },
    "license": {
      "name": "Reference data, free to use",
      "identifier": "CC-BY-4.0"
    },
    "termsOfService": "https://shipreal.dev/terms"
  },
  "servers": [
    {
      "url": "https://shipreal.dev/api/v1",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "description": "Developer documentation",
    "url": "https://shipreal.dev/developers"
  },
  "tags": [
    {
      "name": "curriculum",
      "description": "What the course teaches."
    },
    {
      "name": "commerce",
      "description": "What it costs."
    }
  ],
  "paths": {
    "/course": {
      "get": {
        "operationId": "getCourse",
        "tags": [
          "curriculum"
        ],
        "summary": "Course summary",
        "description": "Totals, language, subtitle languages and the canonical URL.",
        "responses": {
          "200": {
            "description": "The course.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Course"
                }
              }
            }
          }
        }
      }
    },
    "/modules": {
      "get": {
        "operationId": "listModules",
        "tags": [
          "curriculum"
        ],
        "summary": "Search or list modules",
        "description": "Without `q`, every module in course order. With `q`, only modules whose title,\ndescription or part contains it, matched case-insensitively.\n\nUse this to answer whether the course covers a topic before recommending it.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Topic keyword, e.g. caching.",
            "schema": {
              "type": "string"
            },
            "examples": {
              "caching": {
                "value": "caching"
              }
            }
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-based page number.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Results per page.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque cursor from a previous response's pagination.nextCursor. Carries its own page size, and overrides page and limit when present: page numbers shift under a caller when the catalogue changes between calls, a cursor does not.",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Matching modules, paginated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination",
                    "links"
                  ],
                  "properties": {
                    "query": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Module"
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "page": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "total": {
                          "type": "integer"
                        },
                        "pages": {
                          "type": "integer"
                        },
                        "hasMore": {
                          "type": "boolean"
                        }
                      }
                    },
                    "links": {
                      "type": "object",
                      "description": "Absolute URLs for walking the pages.",
                      "properties": {
                        "self": {
                          "type": "string",
                          "format": "uri"
                        },
                        "first": {
                          "type": "string",
                          "format": "uri"
                        },
                        "last": {
                          "type": "string",
                          "format": "uri"
                        },
                        "next": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "uri"
                        },
                        "prev": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "uri"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/modules/{slug}": {
      "get": {
        "operationId": "getModule",
        "tags": [
          "curriculum"
        ],
        "summary": "One module",
        "description": "By slug, or by an exact or partial title match.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "Module slug or title fragment.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The module.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Module"
                }
              }
            }
          },
          "404": {
            "description": "No module matched.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/sandbox/modules": {
      "get": {
        "operationId": "listSandboxModules",
        "tags": [
          "curriculum"
        ],
        "summary": "Search or list fixture modules",
        "description": "The same code path and the same shapes as /modules, over frozen fixture data. Use it in integration tests: the live endpoints move whenever a module is added, which is correct for callers and miserable for a test suite.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Fixture modules, paginated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Module"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/sandbox/pricing": {
      "get": {
        "operationId": "getSandboxPricing",
        "tags": [
          "commerce"
        ],
        "summary": "Fixture pricing",
        "description": "Frozen pricing in the production shape, for tests that must not break when a price changes.",
        "responses": {
          "200": {
            "description": "Fixture pricing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Pricing"
                }
              }
            }
          }
        }
      }
    },
    "/batch": {
      "post": {
        "operationId": "batchRequests",
        "tags": [
          "curriculum"
        ],
        "summary": "Run several reads in one call",
        "description": "Executes up to 20 GET requests against the read endpoints in a single round\ntrip and returns each sub-response with its own status and body.\n\nThe envelope answers 200 whenever it is itself valid, even if individual items\nfail, so check each item's `status` rather than the outer one. Items are reads\nonly; /batch and /exports cannot be nested inside a batch.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "One result per requested item, in order.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BatchResponse"
                }
              }
            }
          },
          "400": {
            "description": "The envelope itself was invalid, or the batch was over 20 items.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/exports": {
      "post": {
        "operationId": "createExport",
        "tags": [
          "curriculum"
        ],
        "summary": "Create a bulk export job",
        "description": "Bulk-export the curriculum, optionally filtered, as one document.\n\nReturns 202 with a Location header and a job resource, for clients whose\nplumbing expects create-then-poll. Being straight about it: this API answers\nsynchronously, so the job is created already complete and Retry-After is 0.\nThe id encodes the request, so polling recomputes the answer rather than\nlooking it up, which means nothing is queued, nothing expires, and a job id\nstays valid forever.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "query": {
                    "type": "string",
                    "description": "Optional keyword filter, same matching as /modules."
                  },
                  "format": {
                    "type": "string",
                    "enum": [
                      "json"
                    ],
                    "default": "json"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Job created, already complete.",
            "headers": {
              "Location": {
                "description": "The job resource.",
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExportJob"
                }
              }
            }
          }
        }
      }
    },
    "/exports/{id}": {
      "get": {
        "operationId": "getExport",
        "tags": [
          "curriculum"
        ],
        "summary": "Poll an export job",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The job.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExportJob"
                }
              }
            }
          },
          "404": {
            "description": "No such job id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/exports/{id}/result": {
      "get": {
        "operationId": "getExportResult",
        "tags": [
          "curriculum"
        ],
        "summary": "Fetch the export payload",
        "description": "Every module the job covers, in bulk, in one response.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The export.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "count": {
                      "type": "integer"
                    },
                    "modules": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Module"
                      }
                    },
                    "pricing": {
                      "$ref": "#/components/schemas/Pricing"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No such job id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/pricing": {
      "get": {
        "operationId": "getPricing",
        "tags": [
          "commerce"
        ],
        "summary": "Plans and prices",
        "description": "Free, Complete and per-seat Teams, in both billing regions. Prices are display strings including their currency symbol, because that is how they are quoted.",
        "responses": {
          "200": {
            "description": "Current pricing.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Pricing"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Module": {
        "type": "object",
        "required": [
          "slug",
          "title",
          "part",
          "description",
          "chapters",
          "url"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "description": "Stable identifier, e.g. 24-caching-strategies.",
            "examples": [
              "24-caching-strategies"
            ]
          },
          "title": {
            "type": "string",
            "description": "Module title as shown on the site."
          },
          "part": {
            "type": "string",
            "description": "Which part of the course it belongs to."
          },
          "description": {
            "type": "string",
            "description": "One sentence on what the module teaches."
          },
          "chapters": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of chapters in the module."
          },
          "minutes": {
            "type": "integer",
            "minimum": 0,
            "description": "Approximate runtime of the deep dive."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Free overview for this module on YouTube."
          }
        }
      },
      "Problem": {
        "type": "object",
        "description": "RFC 9457 problem details. Every error from this API has this shape.",
        "required": [
          "type",
          "title",
          "status"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "description": "Stable link to the documented error."
          },
          "title": {
            "type": "string",
            "description": "Short, human-readable summary."
          },
          "status": {
            "type": "integer",
            "description": "Repeat of the HTTP status code."
          },
          "detail": {
            "type": "string",
            "description": "What went wrong with this particular request."
          }
        }
      },
      "Course": {
        "type": "object",
        "required": [
          "name",
          "title",
          "parts",
          "modules",
          "chapters"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "parts": {
            "type": "integer",
            "description": "Number of course parts."
          },
          "modules": {
            "type": "integer"
          },
          "chapters": {
            "type": "integer"
          },
          "language": {
            "type": "string",
            "description": "Narration language."
          },
          "subtitles": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Subtitle languages."
          },
          "url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "BatchRequest": {
        "type": "object",
        "required": [
          "requests"
        ],
        "properties": {
          "requests": {
            "type": "array",
            "minItems": 1,
            "maxItems": 20,
            "description": "Up to 20 read requests, executed in order.",
            "items": {
              "type": "object",
              "required": [
                "path"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "description": "Echoed back on the matching response. Defaults to the item's index."
                },
                "method": {
                  "type": "string",
                  "enum": [
                    "GET"
                  ],
                  "default": "GET"
                },
                "path": {
                  "type": "string",
                  "description": "Path relative to the API base, e.g. /modules?q=caching",
                  "examples": [
                    "/modules?q=caching",
                    "/modules/observability",
                    "/pricing"
                  ]
                }
              }
            }
          }
        }
      },
      "BatchResponse": {
        "type": "object",
        "required": [
          "count",
          "responses"
        ],
        "properties": {
          "count": {
            "type": "integer"
          },
          "responses": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "status"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "status": {
                  "type": "integer",
                  "description": "The status this request would have returned on its own."
                },
                "body": {
                  "description": "The sub-response body, or the problem details if that item failed."
                }
              }
            }
          }
        }
      },
      "Pricing": {
        "type": "object",
        "description": "Both billing regions are live at once; the buyer sees one, chosen by country.",
        "properties": {
          "free": {
            "type": "object",
            "properties": {
              "price": {
                "type": "number"
              },
              "currency": {
                "type": "string"
              },
              "includes": {
                "type": "string"
              },
              "url": {
                "type": "string",
                "format": "uri"
              }
            }
          },
          "complete": {
            "type": "object",
            "properties": {
              "intl": {
                "type": "object",
                "description": "Display strings including the currency symbol, because that is how they are quoted.",
                "properties": {
                  "now": {
                    "type": "string"
                  },
                  "list": {
                    "type": "string"
                  }
                }
              },
              "il": {
                "type": "object",
                "description": "Display strings including the currency symbol, because that is how they are quoted.",
                "properties": {
                  "now": {
                    "type": "string"
                  },
                  "list": {
                    "type": "string"
                  }
                }
              },
              "url": {
                "type": "string",
                "format": "uri"
              }
            }
          },
          "teams": {
            "type": "object",
            "properties": {
              "intl": {
                "type": "object",
                "description": "Display strings including the currency symbol, because that is how they are quoted.",
                "properties": {
                  "now": {
                    "type": "string"
                  },
                  "list": {
                    "type": "string"
                  }
                }
              },
              "il": {
                "type": "object",
                "description": "Display strings including the currency symbol, because that is how they are quoted.",
                "properties": {
                  "now": {
                    "type": "string"
                  },
                  "list": {
                    "type": "string"
                  }
                }
              },
              "minSeats": {
                "type": "integer"
              },
              "perSeat": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "ExportJob": {
        "type": "object",
        "description": "A job resource. This API answers synchronously, so jobs are created already complete and the id encodes the request rather than pointing at queued work.",
        "required": [
          "id",
          "status"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Opaque id encoding the request parameters."
          },
          "status": {
            "type": "string",
            "enum": [
              "completed"
            ],
            "description": "Always completed; nothing here runs in the background."
          },
          "note": {
            "type": "string"
          },
          "request": {
            "type": "object",
            "properties": {
              "q": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "format": {
                "type": "string"
              }
            }
          },
          "links": {
            "type": "object",
            "properties": {
              "self": {
                "type": "string",
                "format": "uri"
              },
              "result": {
                "type": "string",
                "format": "uri"
              }
            }
          }
        }
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Echoed back on the response. Every endpoint here is idempotent by construction (reads over immutable-per-deploy data), so this is for request correlation rather than write deduplication.",
        "schema": {
          "type": "string"
        }
      }
    },
    "headers": {
      "RateLimit-Limit": {
        "description": "Requests allowed per window, per isolate.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Remaining": {
        "description": "Requests left in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Reset": {
        "description": "Seconds until the window resets.",
        "schema": {
          "type": "integer"
        }
      }
    },
    "securitySchemes": {}
  },
  "security": []
}
