{
  "openapi": "3.1.0",
  "info": {
    "title": "Morfee",
    "version": "0.1.0",
    "summary": "HTML templates plus JSON, a PDF from one API call.",
    "description": "Everything the builder can do, this API can do; the builder is a shell around it.\n\nThis document is derived from the route table the server actually dispatches on, so it cannot\ndescribe an endpoint that is not there or leave one out. The prose version of the same thing is at\n`/docs.md`, and it is worth reading first: it explains the five template helpers, what a document is\nnot allowed to fetch, and how to drive this from Power Automate.\n\nA key carries a scope, and every operation says which one it needs, in its description and in\n`x-morfee-scope`. There are four: `read` may only read, `render` may read and make documents, `write`\nmay read and change templates and folders, `full` may do everything including minting keys. A key too\nnarrow for the call is a 403 that names the scope it has and how to get a wider one. The combination\nworth having in production is `render`: a leaked key can then make documents and cannot touch the\nlibrary it makes them from.\n\nTwo things that are true of every route here and are therefore not repeated on each of them.\nA request that changes something and arrives with an `Origin` header that is not ours is refused with\n403 before it reaches a handler; a call carrying an API key is not affected, because a key is a\ndeliberate act by whoever holds it. And an unexpected fault answers 500 with a `reference` that names\nthe line in our log, never the message from the exception.\n\nWhat is not in here: the screens. The library, the builder, the keys page and the rest are reachable\nonly by a signed-in person and refuse a key outright, so a client built from this document could not\nuse them if it tried. The few pages that are in here are the ones a stranger can reach, plus two that\nanswer a file rather than a screen.",
    "contact": {
      "name": "furo solutions",
      "url": "https://furo.solutions"
    }
  },
  "servers": [
    {
      "url": "https://morfee.furo.solutions",
      "description": "This installation, as this request reached it."
    }
  ],
  "tags": [
    {
      "name": "Documents"
    },
    {
      "name": "Service"
    },
    {
      "name": "Pages"
    },
    {
      "name": "Usage"
    },
    {
      "name": "Templates"
    },
    {
      "name": "Folders"
    },
    {
      "name": "Keys"
    },
    {
      "name": "Bin"
    },
    {
      "name": "MCP"
    },
    {
      "name": "Files"
    }
  ],
  "paths": {
    "/api/download/{id}": {
      "get": {
        "tags": [
          "Documents"
        ],
        "operationId": "get__api_download_id",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The uuid out of the url you were given."
          }
        ],
        "summary": "Fetch a document made with ?format=url",
        "description": "No key. The URL is the secret, which is what makes it useful to hand to somebody who has none. Valid for an hour. Without object storage it lives in the memory of the process that made it, so a restart or a busy hour can end one sooner.",
        "security": [],
        "responses": {
          "200": {
            "description": "The document.",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-morfee-route": "GET ^/api/download/([a-f0-9-]{36})$",
        "x-morfee-access": "open"
      }
    },
    "/api/mollie/webhook": {
      "post": {
        "tags": [
          "Service"
        ],
        "operationId": "post__api_mollie_webhook",
        "parameters": [],
        "summary": "Where Mollie tells us a payment moved",
        "security": [],
        "description": "Not for you to call, and documented because it is open rather than because it is useful. The body is not believed: it carries an id, and what that id did is asked at Mollie with our own key. So the answer is always 200, even for an id that was never ours, because a retry cannot fix that.",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "examples": [
                      "tr_abc123"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Always.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                }
              }
            }
          }
        },
        "x-morfee-route": "POST /api/mollie/webhook",
        "x-morfee-access": "open"
      }
    },
    "/api/status": {
      "get": {
        "tags": [
          "Service"
        ],
        "operationId": "get__api_status",
        "parameters": [],
        "summary": "Is the service up",
        "security": [],
        "description": "Open, because a monitor should not need a key. Send one anyway and it also says who you are.",
        "responses": {
          "200": {
            "description": "Up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Status"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /api/status",
        "x-morfee-access": "open"
      }
    },
    "/api/health": {
      "get": {
        "tags": [
          "Service"
        ],
        "operationId": "get__api_health",
        "parameters": [],
        "summary": "What this installation thinks of itself",
        "security": [],
        "description": "This is where an uptime monitor hangs. 503 the moment one point is red.",
        "responses": {
          "200": {
            "description": "Everything green.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          },
          "503": {
            "description": "At least one point is red. The body says which.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /api/health",
        "x-morfee-access": "open"
      }
    },
    "/how-it-works": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__how_it_works",
        "parameters": [],
        "security": [],
        "summary": "What Morfee is, for somebody who has never seen it.",
        "responses": {
          "200": {
            "description": "What Morfee is, for somebody who has never seen it.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /how-it-works",
        "x-morfee-access": "open"
      }
    },
    "/pricing": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__pricing",
        "parameters": [],
        "security": [],
        "summary": "What it costs.",
        "responses": {
          "200": {
            "description": "What it costs.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /pricing",
        "x-morfee-access": "open"
      }
    },
    "/privacy": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__privacy",
        "parameters": [],
        "security": [],
        "summary": "What is stored, where it stands, how long it stays, and who else touches it.",
        "responses": {
          "200": {
            "description": "What is stored, where it stands, how long it stays, and who else touches it.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /privacy",
        "x-morfee-access": "open"
      }
    },
    "/terms": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__terms",
        "parameters": [],
        "security": [],
        "summary": "What the service promises, what it costs, and what happens when either side stops.",
        "responses": {
          "200": {
            "description": "What the service promises, what it costs, and what happens when either side stops.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /terms",
        "x-morfee-access": "open"
      }
    },
    "/processing": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__processing",
        "parameters": [],
        "security": [],
        "summary": "The GDPR processor agreement, applying to every account as it stands.",
        "responses": {
          "200": {
            "description": "The GDPR processor agreement, applying to every account as it stands.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /processing",
        "x-morfee-access": "open"
      }
    },
    "/power-automate": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__power_automate",
        "parameters": [],
        "security": [],
        "summary": "How to call /api/render from a Power Automate HTTP action.",
        "responses": {
          "200": {
            "description": "How to call /api/render from a Power Automate HTTP action.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /power-automate",
        "x-morfee-access": "open"
      }
    },
    "/quote-to-pdf": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__quote_to_pdf",
        "parameters": [],
        "security": [],
        "summary": "How to generate a quote PDF, with a bundled worked example.",
        "responses": {
          "200": {
            "description": "How to generate a quote PDF, with a bundled worked example.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /quote-to-pdf",
        "x-morfee-access": "open"
      }
    },
    "/invoice-to-pdf": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__invoice_to_pdf",
        "parameters": [],
        "security": [],
        "summary": "How to generate an invoice PDF, and what it needs beyond a quote.",
        "responses": {
          "200": {
            "description": "How to generate an invoice PDF, and what it needs beyond a quote.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /invoice-to-pdf",
        "x-morfee-access": "open"
      }
    },
    "/errors": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__errors",
        "parameters": [],
        "security": [],
        "summary": "What every error code means. The address in the `type` field of a problem answer lands here.",
        "responses": {
          "200": {
            "description": "What every error code means. The address in the `type` field of a problem answer lands here.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /errors",
        "x-morfee-access": "open"
      }
    },
    "/docs": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__docs",
        "parameters": [
          {
            "name": "Accept",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Markdown is answered only when it is genuinely preferred over HTML, by q-value."
          }
        ],
        "summary": "The documentation",
        "security": [],
        "description": "HTML for a person. Send `Accept: text/markdown` and the same text comes back as markdown, which is what /docs.md answers as well.",
        "responses": {
          "200": {
            "description": "The documentation.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              },
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /docs",
        "x-morfee-access": "open"
      }
    },
    "/docs.md": {
      "get": {
        "tags": [
          "Service"
        ],
        "operationId": "get__docs_md",
        "parameters": [],
        "summary": "The documentation as markdown",
        "security": [],
        "description": "The same text as /docs, in the form an assistant would rather read.",
        "responses": {
          "200": {
            "description": "The documentation.",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /docs.md",
        "x-morfee-access": "open"
      }
    },
    "/.well-known/agent-skills/index.json": {
      "get": {
        "tags": [
          "Service"
        ],
        "operationId": "get__well_known_agent_skills_index_json",
        "parameters": [],
        "summary": "Which skills this installation publishes",
        "security": [],
        "description": "The Agent Skills discovery index. Open, because the whole point is that a client can find the skill before anybody has been given a key. Answered at both /.well-known/agent-skills/index.json and the older /.well-known/skills/index.json, since clients written on either side of that rename are in the wild.",
        "responses": {
          "200": {
            "description": "The index.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "x-morfee-route": "ANY ^/\\.well-known/(?:agent-)?skills/index\\.json$",
        "x-morfee-access": "open"
      }
    },
    "/.well-known/agent-skills/morfee/SKILL.md": {
      "get": {
        "tags": [
          "Service"
        ],
        "operationId": "get__well_known_agent_skills_morfee_SKILL_md",
        "parameters": [],
        "summary": "The skill itself",
        "security": [],
        "description": "How to drive Morfee, written for an agent rather than for a person: the loop, the render call, retrying safely, and which error codes are worth handling.",
        "responses": {
          "200": {
            "description": "The skill.",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /.well-known/agent-skills/morfee/SKILL.md",
        "x-morfee-access": "open"
      }
    },
    "/openapi.json": {
      "get": {
        "tags": [
          "Service"
        ],
        "operationId": "get__openapi_json",
        "parameters": [],
        "summary": "This document",
        "security": [],
        "description": "Open, because a machine has to be able to read the spec before it has a key.",
        "responses": {
          "200": {
            "description": "This document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /openapi.json",
        "x-morfee-access": "open"
      }
    },
    "/sign-in": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__sign_in",
        "parameters": [],
        "security": [],
        "summary": "The sign-in form.",
        "responses": {
          "200": {
            "description": "The sign-in form.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /sign-in",
        "x-morfee-access": "open"
      },
      "post": {
        "tags": [
          "Pages"
        ],
        "operationId": "post__sign_in",
        "parameters": [],
        "summary": "Ask for a sign-in link",
        "security": [],
        "description": "One address in, one link out by mail. There is no password anywhere in this product.",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                },
                "required": [
                  "email"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The link has gone out. The same page for a known and an unknown address, on purpose.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "That does not look like an email address.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "No mail provider is connected, or it did not answer. Nothing is wrong with the address.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "POST /sign-in",
        "x-morfee-access": "open"
      }
    },
    "/sign-up": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__sign_up",
        "parameters": [],
        "security": [],
        "summary": "The same machinery as signing in, behind a door somebody new can find.",
        "responses": {
          "200": {
            "description": "The same machinery as signing in, behind a door somebody new can find.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /sign-up",
        "x-morfee-access": "open"
      },
      "post": {
        "tags": [
          "Pages"
        ],
        "operationId": "post__sign_up",
        "parameters": [],
        "summary": "Ask for a link, from the other door",
        "security": [],
        "description": "An account is made the moment the link is followed, so this is the same act as signing in.",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                },
                "required": [
                  "email"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The link has gone out.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "That does not look like an email address.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "No mail provider is connected, or it did not answer.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "POST /sign-up",
        "x-morfee-access": "open"
      }
    },
    "/sign-in/link": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__sign_in_link",
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Redeem a sign-in link",
        "security": [],
        "description": "The token in the query is the credential, so guessing it is rate limited per address and per network.",
        "responses": {
          "303": {
            "description": "Signed in, and off to the library.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "The link is spent, expired or was never one of ours.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-morfee-route": "GET /sign-in/link",
        "x-morfee-access": "open"
      }
    },
    "/sign-out": {
      "post": {
        "tags": [
          "Pages"
        ],
        "operationId": "post__sign_out",
        "parameters": [],
        "summary": "End this session",
        "security": [],
        "responses": {
          "303": {
            "description": "To the sign-in page.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "POST /sign-out",
        "x-morfee-access": "open"
      }
    },
    "/inbox": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__inbox",
        "parameters": [],
        "security": [],
        "summary": "Every link that has been handed out. Local installations only: the route does not exist on one that is reachable from outside.",
        "responses": {
          "200": {
            "description": "Every link that has been handed out. Local installations only: the route does not exist on one that is reachable from outside.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /inbox",
        "x-morfee-access": "open"
      }
    },
    "/inbox/clear": {
      "post": {
        "tags": [
          "Pages"
        ],
        "operationId": "post__inbox_clear",
        "parameters": [],
        "security": [],
        "summary": "Empty that list. Local installations only.",
        "responses": {
          "303": {
            "description": "Back to the screen, with a notice on it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "POST /inbox/clear",
        "x-morfee-access": "open"
      }
    },
    "/organization/start": {
      "post": {
        "tags": [
          "Pages"
        ],
        "operationId": "post__organization_start",
        "parameters": [],
        "summary": "Start an organization when you are in none",
        "security": [],
        "description": "For somebody who is signed in and belongs to nowhere: removed from one, or signed in with a company address where colleagues already work, so no organization was made for them. Refuses once they have one.",
        "responses": {
          "303": {
            "description": "Into the new organization, or to the sign-in page first.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "POST /organization/start",
        "x-morfee-access": "open"
      }
    },
    "/invitation/{token}": {
      "get": {
        "tags": [
          "Pages"
        ],
        "operationId": "get__invitation_token",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "An invitation to an organization",
        "security": [],
        "description": "Open because the person holding the link does not have an organization yet. The token is the credential.",
        "responses": {
          "200": {
            "description": "What you are being invited to.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "Spent, expired or never ours.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "ANY ^/invitation/([A-Za-z0-9_-]+)$",
        "x-morfee-access": "open"
      },
      "post": {
        "tags": [
          "Pages"
        ],
        "operationId": "post__invitation_token",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Accept an invitation",
        "security": [],
        "description": "Accepting still requires being signed in; somebody who is not is sent to sign in first.",
        "responses": {
          "303": {
            "description": "Into the organization, or to the sign-in page first.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "Spent, expired or never ours.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "ANY ^/invitation/([A-Za-z0-9_-]+)$",
        "x-morfee-access": "open"
      }
    },
    "/api/usage": {
      "get": {
        "tags": [
          "Usage"
        ],
        "operationId": "get__api_usage",
        "parameters": [],
        "summary": "The agreement, and what has been used against it",
        "description": "Ask this before you need it. The end date is here as well as in the 402 you get once it has passed, so an integration can warn somebody while their documents still come out.\n\nAny scope may call this: every key may read.",
        "responses": {
          "200": {
            "description": "Plan, limits and use.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Usage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "x-morfee-route": "GET /api/usage",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      }
    },
    "/api/templates": {
      "get": {
        "tags": [
          "Templates"
        ],
        "operationId": "get__api_templates",
        "parameters": [
          {
            "name": "search",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Matches on name, and deliberately ignores `folder`: when you are looking for a name you do not want to be told it sits somewhere you were not looking."
          },
          {
            "name": "folder",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "A folder id, or `none` for everything that is in no folder."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 500
            },
            "description": "Anything above 500 is treated as 500. Anything unreadable falls back to 100."
          },
          {
            "name": "offset",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 0
            }
          }
        ],
        "summary": "List templates",
        "description": "A page at a time. Loop on `nextOffset` until it is null and you have the lot without having to work out from the counts whether you are done.\n\nAny scope may call this: every key may read.",
        "responses": {
          "200": {
            "description": "A page of templates.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TemplateList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "x-morfee-route": "GET /api/templates",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      },
      "post": {
        "tags": [
          "Templates"
        ],
        "operationId": "post__api_templates",
        "parameters": [],
        "summary": "Save or update a template",
        "description": "Send `id` to update, leave it out for a new one. A new one needs room in the plan; an update never does, otherwise you would be stuck with what you have the moment you hit the ceiling.\n\nNeeds a key scoped `write` or `full`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SaveTemplate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "template": {
                      "$ref": "#/components/schemas/TemplateMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          }
        },
        "x-morfee-route": "POST /api/templates",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      }
    },
    "/api/folders": {
      "get": {
        "tags": [
          "Folders"
        ],
        "operationId": "get__api_folders",
        "parameters": [],
        "summary": "List folders",
        "description": "One level deep, with a count of what is on each shelf.\n\nAny scope may call this: every key may read.",
        "responses": {
          "200": {
            "description": "The folders.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "folders": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Folder"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "x-morfee-route": "GET /api/folders",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      },
      "post": {
        "tags": [
          "Folders"
        ],
        "operationId": "post__api_folders",
        "parameters": [],
        "summary": "Make a folder",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  }
                },
                "required": [
                  "name"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The folder.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "folder": {
                      "$ref": "#/components/schemas/Folder"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          }
        },
        "description": "Needs a key scoped `write` or `full`.",
        "x-morfee-route": "POST /api/folders",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      }
    },
    "/api/folders/{id}": {
      "get": {
        "tags": [
          "Folders"
        ],
        "operationId": "get__api_folders_id",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "One folder and everything on it",
        "responses": {
          "200": {
            "description": "The folder and its templates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "folder": {
                      "$ref": "#/components/schemas/Folder"
                    },
                    "templates": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/TemplateMeta"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "description": "Any scope may call this: every key may read.",
        "x-morfee-route": "ANY ^/api/folders/([a-f0-9]+)$",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      },
      "post": {
        "tags": [
          "Folders"
        ],
        "operationId": "post__api_folders_id",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Rename a folder",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  }
                },
                "required": [
                  "name"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Renamed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "folder": {
                      "$ref": "#/components/schemas/Folder"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "description": "Needs a key scoped `write` or `full`.",
        "x-morfee-route": "ANY ^/api/folders/([a-f0-9]+)$",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      },
      "delete": {
        "tags": [
          "Folders"
        ],
        "operationId": "delete__api_folders_id",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Delete a folder",
        "description": "A folder is a shelf, not a container: what was on it is still there, in no folder.\n\nNeeds a key scoped `write` or `full`.",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "deleted": {
                      "type": "string"
                    },
                    "hint": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-morfee-route": "ANY ^/api/folders/([a-f0-9]+)$",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      }
    },
    "/api/keys": {
      "get": {
        "tags": [
          "Keys"
        ],
        "operationId": "get__api_keys",
        "parameters": [],
        "summary": "List the keys of this organization",
        "description": "Including the revoked and the expired ones, because \"when did this stop working\" is the question this list gets asked. `scopes` is the vocabulary, so a caller does not have to hard-code it.\n\nAny scope may call this: every key may read.",
        "responses": {
          "200": {
            "description": "Every key, and the scopes that exist.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "keys": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ApiKey"
                      }
                    },
                    "scopes": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "examples": [
                        [
                          "read",
                          "render",
                          "write",
                          "full"
                        ]
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "x-morfee-route": "GET /api/keys",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      },
      "post": {
        "tags": [
          "Keys"
        ],
        "operationId": "post__api_keys",
        "parameters": [],
        "summary": "Mint a key",
        "description": "The `key` field of the answer is the only time the secret is readable, here or anywhere: what is stored is a hash. Store it in the same step that makes it, because a caller that does not has no way back to it and has to make another one.\n\nA key can only make one as narrow as itself, so a `render` key asking for `full` is a 403 rather than a quietly narrowed key. Give every system its own, then revoking one upsets nothing else.\n\nNeeds a key scoped `write` or `full`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NewKey"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The key, once.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "key": {
                      "type": "string",
                      "description": "The secret. This is the only answer it ever appears in."
                    },
                    "id": {
                      "type": "string"
                    },
                    "prefix": {
                      "type": "string"
                    },
                    "scope": {
                      "type": "string",
                      "enum": [
                        "read",
                        "render",
                        "write",
                        "full"
                      ]
                    },
                    "expiresAt": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time"
                    },
                    "hint": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "No name, a scope that does not exist, an expiry that is not a date, or one that has already passed. The message says which, and the hint shows a body that would work.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Two things land here. A key too narrow to make keys at all, and a key wide enough to make keys asking for a scope wider than its own.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-morfee-route": "POST /api/keys",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      }
    },
    "/api/keys/{id}/revoke": {
      "post": {
        "tags": [
          "Keys"
        ],
        "operationId": "post__api_keys_id_revoke",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Revoke a key",
        "description": "Immediate: anything holding that key stops working on its next call. Revoking the key you are calling with is allowed on purpose, and `wasThisKey` says that is what happened. A key that has leaked is exactly when you want that, and refusing would make the fastest way to close the hole the one thing this API would not do.\n\nNeeds a key scoped `write` or `full`.",
        "responses": {
          "200": {
            "description": "Revoked, or already was.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "revoked": {
                      "type": "string"
                    },
                    "wasThisKey": {
                      "type": "boolean",
                      "description": "True when you have just revoked the key you called with."
                    },
                    "alreadyRevoked": {
                      "type": "boolean",
                      "description": "Only present when it was revoked before this call."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          },
          "404": {
            "description": "No key with that id in your organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-morfee-route": "POST ^/api/keys/([a-f0-9]+)/revoke$",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      }
    },
    "/api/bin": {
      "get": {
        "tags": [
          "Bin"
        ],
        "operationId": "get__api_bin",
        "parameters": [],
        "summary": "What sits in the bin",
        "description": "Deleted templates, newest first. They are kept for thirty days.\n\nAny scope may call this: every key may read.",
        "responses": {
          "200": {
            "description": "The bin.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "templates": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BinEntry"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "x-morfee-route": "GET /api/bin",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      }
    },
    "/api/bin/{id}/restore": {
      "post": {
        "tags": [
          "Bin"
        ],
        "operationId": "post__api_bin_id_restore",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Take something back out of the bin",
        "responses": {
          "200": {
            "description": "Back in the library.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "restored": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          },
          "404": {
            "description": "That is not in your bin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "description": "Needs a key scoped `write` or `full`.",
        "x-morfee-route": "POST ^/api/bin/([a-z0-9-]+)/restore$",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      }
    },
    "/api/templates/{id}": {
      "get": {
        "tags": [
          "Templates"
        ],
        "operationId": "get__api_templates_id",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Read one template",
        "description": "Everything, including the template body, the sample JSON and the bindings.\n\nAny scope may call this: every key may read.",
        "responses": {
          "200": {
            "description": "The template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Template"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-morfee-route": "ANY ^/api/templates/([a-z0-9-]+)(/copy|/versions)?$",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      },
      "delete": {
        "tags": [
          "Templates"
        ],
        "operationId": "delete__api_templates_id",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Move a template to the bin",
        "description": "It sits in the bin for thirty days and can be put back from there. Renders on that id fail meanwhile.\n\nNeeds a key scoped `write` or `full`.",
        "responses": {
          "200": {
            "description": "Moved to the bin.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "deleted": {
                      "type": "string"
                    },
                    "hint": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-morfee-route": "ANY ^/api/templates/([a-z0-9-]+)(/copy|/versions)?$",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      }
    },
    "/api/templates/{id}/copy": {
      "post": {
        "tags": [
          "Templates"
        ],
        "operationId": "post__api_templates_id_copy",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Copy a template",
        "description": "A copy with an id of its own. Worth doing before a risky change: nothing warns a flow that the template behind its id has been edited.\n\nNeeds a key scoped `write` or `full`.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Falls back to \"<name> (copy)\"."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The copy.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "template": {
                      "$ref": "#/components/schemas/TemplateMeta"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-morfee-route": "ANY ^/api/templates/([a-z0-9-]+)(/copy|/versions)?$",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      }
    },
    "/api/templates/{id}/versions": {
      "get": {
        "tags": [
          "Templates"
        ],
        "operationId": "get__api_templates_id_versions",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "The kept versions of a template",
        "description": "Every save keeps the previous body. Twenty are kept.\n\nAny scope may call this: every key may read.",
        "responses": {
          "200": {
            "description": "The versions, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "versions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Version"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-morfee-route": "ANY ^/api/templates/([a-z0-9-]+)(/copy|/versions)?$",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      },
      "post": {
        "tags": [
          "Templates"
        ],
        "operationId": "post__api_templates_id_versions",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "Put a version back live",
        "description": "Restoring is itself a save, so the version you are replacing is kept as well.\n\nNeeds a key scoped `write` or `full`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "version": {
                    "type": "integer",
                    "description": "The `id` of the version."
                  }
                },
                "required": [
                  "version"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "That version is live again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "template": {
                      "$ref": "#/components/schemas/TemplateMeta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "x-morfee-route": "ANY ^/api/templates/([a-z0-9-]+)(/copy|/versions)?$",
        "x-morfee-access": "member",
        "x-morfee-scope": "write",
        "x-morfee-scopes-allowed": [
          "write",
          "full"
        ]
      }
    },
    "/api/render": {
      "post": {
        "tags": [
          "Documents"
        ],
        "operationId": "post__api_render",
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "pdf",
                "url",
                "html"
              ]
            },
            "description": "Wins over `format` in the body. `url` is the easy one in Power Automate."
          },
          {
            "name": "templateId",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "An alternative to putting it in the body."
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Send a key of your own (a uuid will do) and this render happens once, however many times the request arrives. A repeat gets the first answer back with `Idempotent-Replay: 1` on it and is not counted or charged again. Keys are kept for 24 hours. The same key with a different body is refused with `422 idempotency_key_reused`; a request still running answers `409 idempotency_in_progress` with `Retry-After`. A render that failed frees its key, so a retry after a broken connection is not stuck on the failure."
          }
        ],
        "summary": "Make a document",
        "description": "The real work. A template plus data comes back as a PDF, as a download link, or as the rendered HTML. This is the only route in the product that costs a render.\n\nNeeds a key scoped `render` or `full`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenderRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The document. Which of the three you get is decided by `format`, and it is the one place in this API where the same call answers three content types.",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                },
                "description": "On the PDF answer."
              }
            },
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RenderUrl"
                }
              },
              "text/html": {
                "schema": {
                  "type": "string"
                },
                "description": "Sandboxed with a Content-Security-Policy: it is your HTML on our address."
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "403": {
            "$ref": "#/components/responses/ScopeTooNarrow"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "description": "A fault at our end, with a reference into the log.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ServerError"
                }
              }
            }
          },
          "502": {
            "$ref": "#/components/responses/RenderFailed"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        },
        "x-morfee-route": "POST /api/render",
        "x-morfee-access": "member",
        "x-morfee-scope": "render",
        "x-morfee-scopes-allowed": [
          "render",
          "full"
        ]
      }
    },
    "/mcp": {
      "get": {
        "tags": [
          "MCP"
        ],
        "operationId": "get__mcp",
        "parameters": [],
        "security": [],
        "summary": "How to point an assistant at this installation (HTML).",
        "responses": {
          "200": {
            "description": "How to point an assistant at this installation (HTML).",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET /mcp",
        "x-morfee-access": "open"
      },
      "post": {
        "tags": [
          "MCP"
        ],
        "operationId": "post__mcp",
        "parameters": [],
        "summary": "The MCP server",
        "description": "JSON-RPC 2.0 over one POST, authenticated with the same API key as the rest of the API. Six tools: list_templates, get_template, template_fields, create_document, preview_document and usage. Deliberately nothing that deletes or rewrites a template; an assistant should be making documents, not editing your library.\n\nAny scope may call this: every key may read.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/JsonRpcRequest"
                  },
                  {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/JsonRpcRequest"
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The answer, or a list of them for a batch.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/JsonRpcResponse"
                    },
                    {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/JsonRpcResponse"
                      }
                    }
                  ]
                }
              }
            }
          },
          "204": {
            "description": "A notification, so there is nothing to answer."
          },
          "400": {
            "description": "Not parseable as JSON. The answer is a JSON-RPC error, not the shared error shape.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JsonRpcResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "x-morfee-route": "POST /mcp",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      }
    },
    "/billing/invoices/{number}.pdf": {
      "get": {
        "tags": [
          "Files"
        ],
        "operationId": "get__billing_invoices_number_pdf",
        "parameters": [
          {
            "name": "number",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[0-9]{4}-[0-9]{4}$"
            },
            "description": "Year and sequence, as on the invoice."
          }
        ],
        "summary": "One invoice as a PDF",
        "description": "Drawn from the row every time, so there is no file that can go missing. Scoped to your organization: an invoice number from another one is a 404 and not a document.\n\nAny scope may call this: every key may read.",
        "responses": {
          "200": {
            "description": "The invoice.",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "303": {
            "$ref": "#/components/responses/SignedOut"
          },
          "404": {
            "description": "No such invoice in your organization.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET ^/billing/invoices/([0-9]{4}-[0-9]{4})\\.pdf$",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      }
    },
    "/preview/{id}": {
      "get": {
        "tags": [
          "Files"
        ],
        "operationId": "get__preview_id",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "summary": "A template rendered with its own sample JSON",
        "description": "HTML, not a PDF, and sandboxed into an origin of its own: it is a document belonging to a customer, answered on our address.\n\nAny scope may call this: every key may read.",
        "responses": {
          "200": {
            "description": "The rendered document.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "303": {
            "$ref": "#/components/responses/SignedOut"
          },
          "404": {
            "description": "No such template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-morfee-route": "GET ^/preview/([a-z0-9-]+)$",
        "x-morfee-access": "member",
        "x-morfee-scope": "read",
        "x-morfee-scopes-allowed": [
          "read",
          "render",
          "write",
          "full"
        ]
      }
    }
  },
  "security": [
    {
      "ApiKeyHeader": []
    },
    {
      "BearerToken": []
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key",
        "description": "A key from /keys, or from POST /api/keys once you have one. It belongs to one organization, reaches nothing outside it, and carries a scope that decides what it may do there."
      },
      "BearerToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "The same key, sent as `Authorization: Bearer <key>`. Equivalent in every way."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Every failure in this API has this shape. `hint` is there when there is something useful to add beyond what went wrong, and `retryAfter` when waiting is the answer.",
        "properties": {
          "error": {
            "type": "string",
            "description": "What went wrong, in a sentence meant to be read."
          },
          "hint": {
            "type": "string",
            "description": "What to do about it, when there is something to say."
          },
          "retryAfter": {
            "type": "integer",
            "description": "Seconds to wait before trying again. Matches the Retry-After header."
          }
        },
        "required": [
          "error"
        ]
      },
      "ServerError": {
        "type": "object",
        "description": "A fault at our end. The message is deliberately not the one from the exception: that wording can carry a path or a hostname of something internal. The reference names the line in the log, so a report about this call can be traced to it.",
        "properties": {
          "error": {
            "type": "string"
          },
          "reference": {
            "type": "string",
            "description": "Six characters. Quote this when reporting the failure."
          }
        },
        "required": [
          "error",
          "reference"
        ]
      },
      "TemplateMeta": {
        "type": "object",
        "description": "A template as the list hands it over: everything except the template body itself.",
        "properties": {
          "id": {
            "type": "string",
            "description": "The id to render with. Lower case, digits and dashes."
          },
          "name": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "createdBy": {
            "type": [
              "string",
              "null"
            ],
            "description": "The email address that saved it first."
          },
          "folderId": {
            "type": [
              "string",
              "null"
            ],
            "description": "null means it is in no folder."
          },
          "bindingCount": {
            "type": "integer",
            "description": "How many fields are pointed at."
          },
          "listCount": {
            "type": "integer",
            "description": "How many of those are lists."
          },
          "byHand": {
            "type": "boolean",
            "description": "True when the template has logic in it but no bindings: written directly rather than built by pointing at a design. The builder cannot rebuild such a template and opens it read only."
          },
          "sizeKb": {
            "type": "integer"
          }
        },
        "required": [
          "id",
          "name"
        ]
      },
      "Template": {
        "type": "object",
        "description": "One template in full, including what the builder needs to open it again.",
        "allOf": [
          {
            "$ref": "#/components/schemas/TemplateMeta"
          }
        ],
        "properties": {
          "hbs": {
            "type": "string",
            "description": "The Handlebars template that gets rendered."
          },
          "source": {
            "type": "string",
            "description": "The HTML that was pasted in, untouched."
          },
          "sample": {
            "description": "The sample JSON this template was built against."
          },
          "bindings": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "What the builder points at."
          },
          "explanation": {
            "type": "array",
            "description": "What the template does, one sentence per step, worked out from the template itself. `depth` is how deep the step sits, so the list draws as an indented outline.",
            "items": {
              "type": "object",
              "properties": {
                "depth": {
                  "type": "integer"
                },
                "text": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "TemplateList": {
        "type": "object",
        "description": "A page of templates, always, whether or not a page was asked for. `nextOffset` is the whole stop condition: ask again with that value until it comes back null.",
        "properties": {
          "templates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TemplateMeta"
            }
          },
          "total": {
            "type": "integer",
            "description": "How many match, not how many are in this answer."
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          },
          "nextOffset": {
            "type": [
              "integer",
              "null"
            ],
            "description": "null on the last page."
          }
        },
        "required": [
          "templates",
          "total",
          "limit",
          "offset",
          "nextOffset"
        ]
      },
      "BinEntry": {
        "type": "object",
        "allOf": [
          {
            "$ref": "#/components/schemas/TemplateMeta"
          }
        ],
        "properties": {
          "deletedAt": {
            "type": "string",
            "format": "date-time"
          },
          "deletedBy": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "Version": {
        "type": "object",
        "description": "One kept version. The field names are the column names, so this is the one answer in the API that is not in camelCase; it is written down as it really is rather than as it should have been.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Send this back as `version` to put it live."
          },
          "name": {
            "type": "string"
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "bytes": {
            "type": "integer",
            "description": "The size of the template body that was kept."
          }
        }
      },
      "Folder": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "templates": {
            "type": "integer",
            "description": "How many templates sit on this shelf."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "name"
        ]
      },
      "SaveTemplate": {
        "type": "object",
        "description": "What POST /api/templates takes. The template is trial-rendered against `sample` before anything is stored: a template that does not render is not saved, and the 400 says why.",
        "properties": {
          "hbs": {
            "type": "string",
            "description": "The template that gets rendered. Required."
          },
          "bindings": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "What the builder needs to open this template again. Send [] if you have none: the handler requires the field to be a list, so leaving it out is a 400."
          },
          "id": {
            "type": "string",
            "description": "Send it to update an existing template. An id belonging to another organization is a 400."
          },
          "name": {
            "type": "string",
            "description": "Falls back to the id."
          },
          "sample": {
            "description": "Sample JSON. Used for the trial render and by the builder."
          },
          "source": {
            "type": "string",
            "description": "Your original HTML, kept untouched."
          },
          "folderId": {
            "type": [
              "string",
              "null"
            ],
            "description": "Put it on this shelf. null takes it out of every folder. Leave the field out entirely and it stays where it is, which is what the builder does."
          },
          "replaceLogic": {
            "type": "boolean",
            "description": "Only needed to overwrite a template that has logic in it with something that has none. Refused with 409 would_drop_logic without it, because that is nearly always a design landing on top of a working template rather than a new version of it."
          }
        },
        "required": [
          "hbs",
          "bindings"
        ]
      },
      "RenderRequest": {
        "type": "object",
        "description": "A document comes about in exactly one of three ways, and everything after that is the same: the same key, the same quota, the same queue, the same formats. Two of the three in one request is a 400 naming both, because a caller who sends both believes something about this call that is not true.",
        "properties": {
          "templateId": {
            "type": "string",
            "description": "A stored template. `template` is accepted as the same field, and so is ?templateId= in the query."
          },
          "template": {
            "type": "string",
            "description": "An older name for templateId. Same thing."
          },
          "hbs": {
            "type": "string",
            "description": "A template that is not stored, rendered with `data`. Held to the same helpers as a stored one, so an invented helper is a 400 that names it."
          },
          "html": {
            "type": "string",
            "description": "Finished HTML. No Handlebars step and no data: what you send is what gets printed."
          },
          "data": {
            "description": "Everything the template refers to. Leave it out and every field of the body that is not one of templateId, template, filename, format, hbs or html becomes the data, which saves a Compose step in Power Automate."
          },
          "filename": {
            "type": "string",
            "description": "The name in the download header. `.pdf` is added if missing."
          },
          "format": {
            "type": "string",
            "enum": [
              "pdf",
              "url",
              "html"
            ],
            "default": "pdf",
            "description": "May also go in the query string, which wins over this field."
          }
        },
        "oneOf": [
          {
            "title": "A stored template plus data",
            "anyOf": [
              {
                "required": [
                  "templateId"
                ]
              },
              {
                "required": [
                  "template"
                ]
              }
            ],
            "properties": {
              "hbs": false,
              "html": false
            }
          },
          {
            "title": "A template that is not stored, plus data",
            "required": [
              "hbs"
            ],
            "properties": {
              "templateId": false,
              "template": false,
              "html": false
            }
          },
          {
            "title": "Finished HTML",
            "required": [
              "html"
            ],
            "properties": {
              "templateId": false,
              "template": false,
              "hbs": false
            }
          }
        ]
      },
      "RenderUrl": {
        "type": "object",
        "description": "The answer to ?format=url. The link needs no key: the URL is the secret.",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "filename": {
            "type": "string"
          },
          "bytes": {
            "type": "integer"
          },
          "validUntil": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "An hour from now. null when object storage is configured on a public bucket, because a plain link there does not expire and saying it does would be a promise you might build on."
          }
        }
      },
      "Usage": {
        "type": "object",
        "description": "`included` and `limit` are two different things and an integration that warns somebody has to know which one it is approaching. Going over `included` costs money and carries on; reaching `limit` stops rendering with a 402. Amounts are in cents.",
        "properties": {
          "organization": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "plan": {
            "type": "string",
            "description": "A label, not the agreement. `custom` means the numbers were set for this customer."
          },
          "planUntil": {
            "type": [
              "string",
              "null"
            ],
            "description": "The day the agreement ends, if it has one."
          },
          "planDaysLeft": {
            "type": [
              "integer",
              "null"
            ]
          },
          "templates": {
            "type": "object",
            "properties": {
              "used": {
                "type": "integer"
              },
              "included": {
                "type": "integer"
              },
              "limit": {
                "type": "integer"
              }
            }
          },
          "renders": {
            "type": "object",
            "properties": {
              "thisMonth": {
                "type": "integer"
              },
              "included": {
                "type": "integer"
              },
              "limit": {
                "type": "integer"
              }
            }
          },
          "price": {
            "type": "object",
            "properties": {
              "currency": {
                "type": "string",
                "examples": [
                  "EUR"
                ]
              },
              "month": {
                "type": "integer",
                "description": "Cents per month."
              },
              "extraRender": {
                "type": "integer",
                "description": "Cents per document over `included`."
              },
              "extraTemplate": {
                "type": "integer",
                "description": "Cents per template over `included`."
              }
            }
          }
        }
      },
      "Status": {
        "type": "object",
        "description": "Open without a key, and it says nothing about any organization unless you identified yourself.",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "pdfEngine": {
            "type": "string"
          },
          "pdfDescription": {
            "type": "string"
          },
          "queue": {
            "$ref": "#/components/schemas/QueueStatus"
          },
          "organization": {
            "type": [
              "object",
              "null"
            ],
            "description": "Only when a key or a session came with the request.",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "plan": {
                "type": "string"
              }
            }
          },
          "templates": {
            "type": [
              "integer",
              "null"
            ],
            "description": "A count, not a list. null without a key."
          }
        }
      },
      "QueueStatus": {
        "type": "object",
        "properties": {
          "workers": {
            "type": "integer"
          },
          "running": {
            "type": "integer"
          },
          "waiting": {
            "type": "integer"
          },
          "maxQueue": {
            "type": "integer"
          },
          "maxWaitMs": {
            "type": "integer"
          },
          "typicalMs": {
            "type": "integer",
            "description": "The 90th percentile of recent renders."
          },
          "estimatedWaitMs": {
            "type": "integer",
            "description": "What a caller joining right now would wait."
          },
          "roomFor": {
            "type": "integer",
            "description": "How many more before new work is refused."
          }
        }
      },
      "Health": {
        "type": "object",
        "description": "What this installation thinks of itself. 503 the moment one point is red, which is what an uptime monitor should hang off. Six points normally: database, pdfEngine, mail, queue, backup and errors, plus offsite when object storage is configured.",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "points": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "ok": {
                  "type": "boolean"
                },
                "text": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "JsonRpcRequest": {
        "type": "object",
        "description": "A single JSON-RPC 2.0 message, or an array of them for a batch.",
        "properties": {
          "jsonrpc": {
            "type": "string",
            "const": "2.0"
          },
          "id": {
            "type": [
              "string",
              "integer",
              "null"
            ],
            "description": "Leave it out for a notification; the answer is then 204."
          },
          "method": {
            "type": "string",
            "examples": [
              "tools/list",
              "tools/call"
            ]
          },
          "params": {
            "type": "object"
          }
        },
        "required": [
          "jsonrpc",
          "method"
        ]
      },
      "JsonRpcResponse": {
        "type": "object",
        "properties": {
          "jsonrpc": {
            "type": "string",
            "const": "2.0"
          },
          "id": {
            "type": [
              "string",
              "integer",
              "null"
            ]
          },
          "result": {
            "type": "object"
          },
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "integer"
              },
              "message": {
                "type": "string"
              }
            }
          }
        }
      },
      "ApiKey": {
        "type": "object",
        "description": "A key as it can be listed. The key itself is not in here and cannot be: only a hash of it is stored, so the prefix is all there is to recognise it by.",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "What it is for. This is how you know which one to revoke later."
          },
          "prefix": {
            "type": "string",
            "description": "The readable front of the key."
          },
          "scope": {
            "type": "string",
            "enum": [
              "read",
              "render",
              "write",
              "full"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "createdBy": {
            "type": [
              "string",
              "null"
            ]
          },
          "lastUsed": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "expiresAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "revokedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "NewKey": {
        "type": "object",
        "description": "What POST /api/keys takes. A name is required: a key you cannot tell apart from the others is a key you cannot revoke with any confidence.",
        "properties": {
          "name": {
            "type": "string",
            "description": "What this key is for."
          },
          "scope": {
            "type": "string",
            "enum": [
              "read",
              "render",
              "write",
              "full"
            ],
            "default": "full",
            "description": "Never wider than the key asking. A `render` key cannot mint a `full` one, otherwise a narrow key would be a full key with one extra call in front of it."
          },
          "expiresInDays": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3650,
            "description": "Or `expiresAt`. Leave both out for a key that does not expire."
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "description": "An absolute moment. In the past is a 400: the key would be dead on arrival."
          }
        },
        "required": [
          "name"
        ]
      },
      "Ok": {
        "type": "object",
        "description": "A confirmation with whatever the call has to hand back next to it.",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Something in the request is wrong, and the message says what.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "No key, an unknown key or a revoked one.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "PaymentRequired": {
        "description": "The ceiling of the plan, or a plan whose end date has passed. The message says which, and the hint points at /billing.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ScopeTooNarrow": {
        "description": "The key is real and the organization is right, but the scope on it does not cover this. The message names the scope the key has, what that scope may do, and how to get a wider one, because the thing reading it is usually the thing that has to act on it.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Not there, or not yours. Both give the same answer on purpose: a different one would let a caller find out which ids exist in another organization.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Over a rate limit. Retry-After says how long to wait, and so does `retryAfter` in the body.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RenderFailed": {
        "description": "The rendering itself went wrong. The answer names the engine that was used.",
        "content": {
          "application/json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Error"
                }
              ],
              "type": "object",
              "properties": {
                "engine": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "Unavailable": {
        "description": "The queue cannot take this on, or a dependency this needs is not there. Retry-After is on it when waiting is the answer.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "SignedOut": {
        "description": "A browser screen asked for without an organization behind it. 303 to the sign-in page rather than a wall, because whoever asked wanted the screen and can have it after signing in.",
        "headers": {
          "Location": {
            "schema": {
              "type": "string"
            }
          }
        }
      }
    }
  }
}