{
  "x-generator": "NSwag v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))",
  "openapi": "3.0.0",
  "info": {
    "title": "Stencilry API",
    "version": "v1"
  },
  "servers": [
    {
      "url": "https://api.stencilry.dev"
    }
  ],
  "paths": {
    "/templates/{id}": {
      "put": {
        "tags": [
          "Templates"
        ],
        "summary": "Saves a Template in place.",
        "description": "Writes the name, description, Liquid body, sample payload and page options as they are given; a request with no page field clears the stored page options. The identifier and Version do not change and no Revision is created; Archive is the only thing that makes revisions. An id naming no Template of the caller's is 404.",
        "operationId": "SaveTemplateEndpoint",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "guid"
            }
          }
        ],
        "requestBody": {
          "x-name": "SaveTemplateRequest",
          "description": "",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SaveTemplateRequest"
              }
            }
          },
          "required": true,
          "x-position": 1
        },
        "responses": {
          "200": {
            "description": "The saved Template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TemplateDetail"
                }
              }
            }
          },
          "400": {
            "description": "The name is missing, the payload is not well-formed JSON, or a page margin is below zero.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "404": {
            "description": "No Active template exists with that id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ]
      },
      "patch": {
        "tags": [
          "Templates"
        ],
        "summary": "Renames a Template.",
        "description": "Writes the name and nothing else: the description, Liquid body and sample payload are left as they are, the Version does not change and no Revision is created. Save replaces all four fields; this does not. An id naming no Template of the caller's is 404.",
        "operationId": "RenameTemplateEndpoint",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "guid"
            }
          }
        ],
        "requestBody": {
          "x-name": "RenameTemplateRequest",
          "description": "",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenameTemplateRequest"
              }
            }
          },
          "required": true,
          "x-position": 1
        },
        "responses": {
          "200": {
            "description": "The renamed Template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TemplateDetail"
                }
              }
            }
          },
          "400": {
            "description": "The name is missing.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "404": {
            "description": "No Active template exists with that id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ]
      },
      "get": {
        "tags": [
          "Templates"
        ],
        "summary": "Fetches one of the caller's templates, active or archived.",
        "description": "Name, description, Liquid body, sample payload, page options, Version and Revision count. A Revision is a template too, so its own id fetches it here; read those ids from the revisions listing. An id naming nothing and another user's id are both 404.",
        "operationId": "GetTemplateEndpoint",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "guid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TemplateDetail"
                }
              }
            }
          },
          "400": {
            "description": "The id is not a GUID.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "404": {
            "description": "No Template exists with that id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ]
      },
      "delete": {
        "tags": [
          "Templates"
        ],
        "summary": "Deletes one of the caller's Templates.",
        "description": "The Template and every Revision under it are removed, explicitly and in one transaction. An id naming no Template of the caller's — including a Revision's own id — is 404, and nothing is removed.",
        "operationId": "DeleteTemplateEndpoint",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "guid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "The Template and its Revisions are gone."
          },
          "400": {
            "description": "The id is not a GUID.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "404": {
            "description": "No Active template exists with that id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ]
      }
    },
    "/templates/{id}/revisions": {
      "get": {
        "tags": [
          "Templates"
        ],
        "summary": "Lists a Template's Revisions.",
        "description": "Newest Version first. Entries are summaries: no body, no payload. Each carries the Revision's own id, which GET /templates/{id} reads it back by — the only route to a Revision's content. A Template never Archived has an empty list, not an error. An id naming no Template of the caller's is 404.",
        "operationId": "ListTemplateRevisionsEndpoint",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "guid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The Revisions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RevisionSummary"
                  }
                }
              }
            }
          },
          "400": {
            "description": "The id is not a GUID.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "404": {
            "description": "No Active template exists with that id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ]
      },
      "post": {
        "tags": [
          "Templates"
        ],
        "summary": "Archives one of the caller's Templates.",
        "description": "The Template's current state — name, description, body, payload — becomes a Revision numbered with the Template's current Version, and the Template moves to the next Version. Archiving unchanged or empty content is allowed. A Note in the body becomes the Template's description first, so the Revision carries it; a request with no body leaves the description as it is. An id naming no Template of the caller's is 404; a concurrent Archive that lost the race is 409.",
        "operationId": "ArchiveTemplateEndpoint",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "The created Revision, with the id GET /templates/{id} reads it back by.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RevisionDetail"
                }
              }
            }
          },
          "400": {
            "description": "The id is not a GUID.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "404": {
            "description": "No Active template exists with that id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "A concurrent Archive claimed the same Version.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ]
      }
    },
    "/templates": {
      "get": {
        "tags": [
          "Templates"
        ],
        "summary": "Lists the caller's Templates.",
        "description": "Most recently updated first. Entries are summaries: no Liquid body, no payload, no Revisions. Not paged.",
        "operationId": "ListTemplatesEndpoint",
        "responses": {
          "200": {
            "description": "The caller's Templates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TemplateSummary"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "source": "# Cheapest call that proves a credential is live. 200 with [] on a new account.\ncurl -s \"https://api.stencilry.dev/templates\" \\\n  -H \"X-Api-Key: $STENCILRY_KEY\" \\\n| jq -r '.[] | \"\\(.id)  v\\(.version)  \\(.name)\"'\n"
          },
          {
            "lang": "C#",
            "source": "using var client = new HttpClient { BaseAddress = new Uri(apiBaseUrl) };\nclient.DefaultRequestHeaders.Add(\"X-Api-Key\", apiKey);\n\nusing var response = await client.GetAsync(\"/templates\", ct);\n\n// The cheapest call that proves a credential is live.\nif (response.StatusCode == HttpStatusCode.Unauthorized)\n{\n    throw new InvalidOperationException(\"The key is missing, malformed or revoked.\");\n}\n\nresponse.EnsureSuccessStatusCode();\n\nvar templates = await response.Content.ReadFromJsonAsync<IReadOnlyList<TemplateSummary>>(ct) ?? [];\n\nforeach (var template in templates)\n{\n    logger.LogInformation(\"{Id} v{Version} {Name}\", template.Id, template.Version, template.Name);\n}\n\npublic sealed record TemplateSummary(\n    Guid Id,\n    string Name,\n    string? Description,\n    int Version,\n    DateTimeOffset CreatedAt,\n    DateTimeOffset UpdatedAt);\n"
          },
          {
            "lang": "TypeScript",
            "source": "type TemplateSummary = {\n  id: string;\n  name: string;\n  description: string | null;\n  version: number;\n  createdAt: string;\n  updatedAt: string;\n};\n\nconst response = await fetch(`${apiBaseUrl}/templates`, {\n  headers: { 'X-Api-Key': apiKey },\n});\n\n// The cheapest call that proves a credential is live.\nif (response.status === 401) {\n  throw new Error('The key is missing, malformed or revoked.');\n}\n\nif (!response.ok) {\n  throw new Error(`List templates failed: ${response.status}`);\n}\n\nconst templates: TemplateSummary[] = await response.json();\n\nfor (const template of templates) {\n  console.log(`${template.id} v${template.version} ${template.name}`);\n}\n"
          }
        ]
      },
      "post": {
        "tags": [
          "Templates"
        ],
        "summary": "Creates a Template.",
        "description": "The name is required; the Liquid body, the sample payload and the page options are optional. Absent page options mean the defaults apply. A new Template is Version 1 with no Revisions. Names need not be unique.",
        "operationId": "CreateTemplateEndpoint",
        "requestBody": {
          "x-name": "CreateTemplateRequest",
          "description": "",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateTemplateRequest"
              }
            }
          },
          "required": true,
          "x-position": 1
        },
        "responses": {
          "201": {
            "description": "The created Template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TemplateDetail"
                }
              }
            }
          },
          "400": {
            "description": "The name is missing, the payload is not well-formed JSON, or a page margin is below zero.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ]
      }
    },
    "/templates/{templateId}/generate": {
      "post": {
        "tags": [
          "Templates"
        ],
        "summary": "Generates a Document from a stored Template.",
        "description": "Renders the Template's current content with the page options stored on it, using the request's payloadJson when supplied and {} when not — the sample payload is never a render default. Assets resolve from the caller's stored assets. On success the response is one JSON body with the base64 PDF and any warnings. Failures are problem details.",
        "operationId": "GenerateDocumentEndpoint",
        "parameters": [
          {
            "name": "templateId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "x-name": "GenerateDocumentRequest",
          "description": "",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GenerateDocumentRequest"
              }
            }
          },
          "required": true,
          "x-position": 1
        },
        "responses": {
          "200": {
            "description": "The base64 Document and any warnings.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GenerateDocumentResponse"
                }
              }
            }
          },
          "400": {
            "description": "The payload is not well-formed JSON, the Template has no Liquid body, or the render failed.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No credential, or one that did not validate."
          },
          "404": {
            "description": "No Active template exists with that id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "The Document rendered but the service could not record the run, so it is not returned. Retry it.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "503": {
            "description": "No render slot came free in time.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "source": "curl -s -X POST \"https://api.stencilry.dev/templates/$TEMPLATE_ID/generate\" \\\n  -H \"X-Api-Key: $STENCILRY_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \"{\\\"payloadJson\\\":$(jq -Rs . < payload.json)}\" \\\n| jq -r .document | base64 -d > invoice.pdf\n"
          },
          {
            "lang": "C#",
            "source": "using var client = new HttpClient { BaseAddress = new Uri(apiBaseUrl) };\nclient.DefaultRequestHeaders.Add(\"X-Api-Key\", apiKey);\n\n// payloadJson is a string holding JSON, not a nested object.\nvar request = new { payloadJson = File.ReadAllText(\"payload.json\") };\n\nusing var response = await client.PostAsJsonAsync($\"/templates/{templateId}/generate\", request, ct);\nresponse.EnsureSuccessStatusCode();\n\nvar result = await response.Content.ReadFromJsonAsync<GenerateDocumentResponse>(ct)\n    ?? throw new InvalidOperationException(\"Empty body.\");\n\nforeach (var warning in result.Warnings)\n{\n    logger.LogWarning(\"{Code}: {Message}\", warning.Code, warning.Message);\n}\n\nawait File.WriteAllBytesAsync(\"invoice.pdf\", Convert.FromBase64String(result.Document), ct);\n\npublic sealed record GenerateDocumentResponse(string Document, IReadOnlyList<RenderWarning> Warnings);\n\npublic sealed record RenderWarning(string Code, string Message, SourceLocation? Location);\n\npublic sealed record SourceLocation(int? Line, int? Column, string? JsonPath);\n"
          },
          {
            "lang": "TypeScript",
            "source": "import { readFile, writeFile } from 'node:fs/promises';\n\ntype SourceLocation = { line: number | null; column: number | null; jsonPath: string | null };\ntype RenderWarning = { code: string; message: string; location: SourceLocation | null };\ntype GenerateDocumentResponse = { document: string; warnings: RenderWarning[] };\n\nconst response = await fetch(`${apiBaseUrl}/templates/${templateId}/generate`, {\n  method: 'POST',\n  headers: {\n    'X-Api-Key': apiKey,\n    'Content-Type': 'application/json',\n  },\n  // payloadJson is a string holding JSON, not a nested object.\n  body: JSON.stringify({ payloadJson: await readFile('payload.json', 'utf8') }),\n});\n\nif (!response.ok) {\n  throw new Error(`Generate failed: ${response.status}`);\n}\n\nconst result: GenerateDocumentResponse = await response.json();\n\nfor (const warning of result.warnings) {\n  console.warn(`${warning.code}: ${warning.message}`);\n}\n\nawait writeFile('invoice.pdf', Buffer.from(result.document, 'base64'));\n"
          }
        ]
      }
    },
    "/render/preview": {
      "post": {
        "tags": [
          "Rendering"
        ],
        "summary": "Renders an HTML template and a JSON payload to a PDF.",
        "description": "Requires an access token or an API key. On success the response is multipart/form-data with two parts: 'diagnostics' (application/json) and 'pdf' (application/pdf, raw bytes). A render that ran and failed returns the same diagnostics JSON on its own, as 422; a request the service could not read at all is 400.",
        "operationId": "RenderPreviewEndpoint",
        "requestBody": {
          "x-name": "RenderPreviewRequest",
          "description": "",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenderPreviewRequest"
              }
            }
          },
          "required": true,
          "x-position": 1
        },
        "responses": {
          "200": {
            "description": "The diagnostics and the rendered PDF.",
            "content": {
              "multipart/form-data": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "The request is malformed.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No credential, or one that did not validate."
          },
          "422": {
            "description": "The template or the payload is invalid. The diagnostics name what broke and where.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RenderDiagnosticsResponse"
                }
              }
            }
          },
          "500": {
            "description": "The render finished but the service could not record the run, so nothing is returned. Retry it.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "503": {
            "description": "No render slot came free in time.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RenderDiagnosticsResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "source": "# 200 is multipart/form-data: a 'diagnostics' JSON part and a 'pdf' part.\ncurl -s -X POST \"https://api.stencilry.dev/render/preview\" \\\n  -H \"X-Api-Key: $STENCILRY_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"templateHtml\": \"<h1>Invoice {{ number }}</h1><p>Total {{ total }}</p>\",\n        \"payloadJson\": \"{\\\"number\\\":\\\"INV-042\\\",\\\"total\\\":\\\"R 3 980.00\\\"}\",\n        \"page\": { \"size\": \"A4\", \"orientation\": \"Portrait\" }\n      }' \\\n  -D headers.txt -o preview.multipart\n\n# The boundary is generated per response. Read it from the header, never assume it.\nboundary=$(sed -n 's/.*boundary=\\\"\\{0,1\\}\\([^\\\";]*\\).*/\\1/p' headers.txt)\n\n# A 422 is not multipart: the body is the diagnostics JSON on its own.\n"
          },
          {
            "lang": "C#",
            "source": "using Microsoft.AspNetCore.WebUtilities;\nusing Microsoft.Net.Http.Headers;\n\nusing var client = new HttpClient { BaseAddress = new Uri(apiBaseUrl) };\nclient.DefaultRequestHeaders.Add(\"X-Api-Key\", apiKey);\n\nvar request = new\n{\n    templateHtml = \"<h1>Invoice {{ number }}</h1><p>Total {{ total }}</p>\",\n    payloadJson = \"\"\"{\"number\":\"INV-042\",\"total\":\"R 3 980.00\"}\"\"\",\n    page = new { size = \"A4\", orientation = \"Portrait\" }\n};\n\nusing var response = await client.PostAsJsonAsync(\"/render/preview\", request, ct);\n\n// A render that ran and failed is 422, with the diagnostics JSON and no PDF.\nif (response.StatusCode == HttpStatusCode.UnprocessableEntity)\n{\n    var failed = await response.Content.ReadFromJsonAsync<RenderDiagnostics>(ct);\n\n    throw new InvalidOperationException(string.Join(\"; \", failed?.Errors.Select(e => e.Message) ?? []));\n}\n\nresponse.EnsureSuccessStatusCode();\n\n// The boundary is generated per response and lives in the Content-Type.\nvar boundary = HeaderUtilities.RemoveQuotes(\n    MediaTypeHeaderValue.Parse(response.Content.Headers.ContentType?.ToString()).Boundary).Value\n    ?? throw new InvalidOperationException(\"No multipart boundary.\");\n\nvar reader = new MultipartReader(boundary, await response.Content.ReadAsStreamAsync(ct));\n\n// Parts arrive in order: diagnostics, then pdf.\nvar diagnosticsSection = await reader.ReadNextSectionAsync(ct)\n    ?? throw new InvalidOperationException(\"No diagnostics part.\");\n\nvar diagnostics = await JsonSerializer.DeserializeAsync<RenderDiagnostics>(\n    diagnosticsSection.Body, cancellationToken: ct);\n\nvar pdfSection = await reader.ReadNextSectionAsync(ct)\n    ?? throw new InvalidOperationException(\"No pdf part.\");\n\nusing var pdf = File.Create(\"preview.pdf\");\nawait pdfSection.Body.CopyToAsync(pdf, ct);\n\npublic sealed record RenderDiagnostics(int PageCount, IReadOnlyList<RenderError> Errors, IReadOnlyList<RenderWarning> Warnings);\n\npublic sealed record RenderError(string Code, string Message, SourceLocation? Location);\n"
          },
          {
            "lang": "TypeScript",
            "source": "import { writeFile } from 'node:fs/promises';\n\ntype SourceLocation = { line: number | null; column: number | null; jsonPath: string | null };\ntype RenderError = { code: string; message: string; location: SourceLocation | null };\ntype RenderWarning = { code: string; message: string; location: SourceLocation | null };\ntype RenderDiagnostics = { pageCount: number; errors: RenderError[]; warnings: RenderWarning[] };\n\nconst response = await fetch(`${apiBaseUrl}/render/preview`, {\n  method: 'POST',\n  headers: {\n    'X-Api-Key': apiKey,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n    templateHtml: '<h1>Invoice {{ number }}</h1><p>Total {{ total }}</p>',\n    payloadJson: '{\"number\":\"INV-042\",\"total\":\"R 3 980.00\"}',\n    page: { size: 'A4', orientation: 'Portrait' },\n  }),\n});\n\n// A render that ran and failed is 422, with the diagnostics JSON and no PDF.\nif (response.status === 422) {\n  const failed: RenderDiagnostics = await response.json();\n  throw new Error(failed.errors.map((error) => error.message).join('; '));\n}\n\nif (!response.ok) {\n  throw new Error(`Preview failed: ${response.status}`);\n}\n\n// formData() parses the multipart body and its generated boundary for you.\nconst parts = await response.formData();\n\n// 'diagnostics' has no filename, so it arrives as a string. 'pdf' has one, so it is a File.\nconst diagnostics: RenderDiagnostics = JSON.parse(parts.get('diagnostics') as string);\nconst pdf = parts.get('pdf') as File;\n\nfor (const warning of diagnostics.warnings) {\n  console.warn(`${warning.code}: ${warning.message}`);\n}\n\nawait writeFile('preview.pdf', Buffer.from(await pdf.arrayBuffer()));\n"
          }
        ]
      }
    },
    "/health": {
      "get": {
        "tags": [
          "Diagnostics"
        ],
        "summary": "Reports that the API host is running.",
        "operationId": "HealthEndpoint",
        "responses": {
          "200": {
            "description": "The host, its build and its clock.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthResponse"
                }
              }
            }
          },
          "401": {
            "description": "An API key was presented and did not validate."
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/capabilities": {
      "get": {
        "tags": [
          "Capabilities"
        ],
        "summary": "What the renderer and the template engine support.",
        "description": "Markdown: what never works, that nothing is reported when something is dropped, and the CSS this deployment's renderer parses and then ignores. The same text the MCP describe_capabilities tool returns.",
        "operationId": "CapabilitiesEndpoint",
        "responses": {
          "200": {
            "description": "The capability digest.",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "description": "An API key was presented and did not validate."
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api-keys/{id}/revoke": {
      "post": {
        "tags": [
          "API keys"
        ],
        "summary": "Revokes one of the caller's keys.",
        "description": "Revoking a key that is already revoked changes nothing and still answers 204. An id naming no key, and an id naming another user's key, are both 404.",
        "operationId": "RevokeApiKeyEndpoint",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "The key is revoked."
          },
          "400": {
            "description": "The id is not a GUID.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "404": {
            "description": "The caller holds no API key with that id.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ]
      }
    },
    "/api-keys": {
      "get": {
        "tags": [
          "API keys"
        ],
        "summary": "The keys the caller holds, most recently updated first.",
        "description": "Revoked keys are included, carrying the instant they were revoked. The token is not returned: only the prefix survives issuing.",
        "operationId": "ListApiKeysEndpoint",
        "responses": {
          "200": {
            "description": "The caller's keys.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ApiKeySummary"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ]
      },
      "post": {
        "tags": [
          "API keys"
        ],
        "summary": "Creates a key and returns its token once.",
        "description": "The token is in this response and in no other. A name the caller already uses on a key that is not revoked is rejected; revoking a key frees its name.",
        "operationId": "CreateApiKeyEndpoint",
        "requestBody": {
          "x-name": "CreateApiKeyRequest",
          "description": "",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateApiKeyRequest"
              }
            }
          },
          "required": true,
          "x-position": 1
        },
        "responses": {
          "201": {
            "description": "The created key and its token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatedApiKeyResponse"
                }
              }
            }
          },
          "400": {
            "description": "The name is missing, or a field is too long.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "No token, or one that did not validate."
          },
          "409": {
            "description": "The caller already holds a key with that name.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "Server Error",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        },
        "security": [
          {
            "JWTBearerAuth": []
          },
          {
            "X-Api-Key": []
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "source": "# The token is in this response and in no other. Store it now.\ncurl -s -X POST \"https://api.stencilry.dev/api-keys\" \\\n  -H \"X-Api-Key: $STENCILRY_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"billing-worker\",\"description\":\"Nightly invoice run\"}' \\\n| jq -r .token\n"
          },
          {
            "lang": "C#",
            "source": "using var client = new HttpClient { BaseAddress = new Uri(apiBaseUrl) };\nclient.DefaultRequestHeaders.Add(\"X-Api-Key\", apiKey);\n\nvar request = new { name = \"billing-worker\", description = \"Nightly invoice run\" };\n\nusing var response = await client.PostAsJsonAsync(\"/api-keys\", request, ct);\n\n// A live key already holds that name. Revoking a key frees its name.\nif (response.StatusCode == HttpStatusCode.Conflict)\n{\n    throw new InvalidOperationException(\"A key named 'billing-worker' already exists.\");\n}\n\nresponse.EnsureSuccessStatusCode();\n\nvar created = await response.Content.ReadFromJsonAsync<CreatedApiKeyResponse>(ct)\n    ?? throw new InvalidOperationException(\"Empty body.\");\n\n// The token is in this response and in no other. Only created.Key.Prefix survives.\nawait secrets.StoreAsync(created.Key.Id, created.Token, ct);\n\npublic sealed record CreatedApiKeyResponse(ApiKeySummary Key, string Token);\n\npublic sealed record ApiKeySummary(\n    Guid Id,\n    string Name,\n    string? Description,\n    string Prefix,\n    DateTimeOffset CreatedAt,\n    DateTimeOffset? RevokedAt);\n"
          },
          {
            "lang": "TypeScript",
            "source": "type ApiKeySummary = {\n  id: string;\n  name: string;\n  description: string | null;\n  prefix: string;\n  createdAt: string;\n  revokedAt: string | null;\n};\n\ntype CreatedApiKeyResponse = { key: ApiKeySummary; token: string };\n\nconst response = await fetch(`${apiBaseUrl}/api-keys`, {\n  method: 'POST',\n  headers: {\n    'X-Api-Key': apiKey,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ name: 'billing-worker', description: 'Nightly invoice run' }),\n});\n\n// A live key already holds that name. Revoking a key frees its name.\nif (response.status === 409) {\n  throw new Error(\"A key named 'billing-worker' already exists.\");\n}\n\nif (!response.ok) {\n  throw new Error(`Create key failed: ${response.status}`);\n}\n\nconst created: CreatedApiKeyResponse = await response.json();\n\n// The token is in this response and in no other. Only created.key.prefix survives.\nawait secrets.store(created.key.id, created.token);\n"
          }
        ]
      }
    }
  },
  "components": {
    "schemas": {
      "TemplateDetail": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "name",
          "version",
          "revisionCount",
          "createdAt",
          "updatedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "guid"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "version": {
            "type": "integer",
            "format": "int32"
          },
          "revisionCount": {
            "type": "integer",
            "format": "int32"
          },
          "body": {
            "type": "string",
            "nullable": true
          },
          "payload": {
            "type": "string",
            "nullable": true
          },
          "page": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/PageOptionsDetail"
              }
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PageOptionsDetail": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "size",
          "orientation",
          "margins"
        ],
        "properties": {
          "size": {
            "$ref": "#/components/schemas/PageSize"
          },
          "orientation": {
            "$ref": "#/components/schemas/PageOrientation"
          },
          "margins": {
            "$ref": "#/components/schemas/PageMarginsDetail"
          }
        }
      },
      "PageSize": {
        "type": "string",
        "description": "",
        "x-enumNames": [
          "A3",
          "A4",
          "A5",
          "Letter",
          "Legal"
        ],
        "enum": [
          "A3",
          "A4",
          "A5",
          "Letter",
          "Legal"
        ]
      },
      "PageOrientation": {
        "type": "string",
        "description": "",
        "x-enumNames": [
          "Portrait",
          "Landscape"
        ],
        "enum": [
          "Portrait",
          "Landscape"
        ]
      },
      "PageMarginsDetail": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "top",
          "right",
          "bottom",
          "left"
        ],
        "properties": {
          "top": {
            "type": "number",
            "format": "double"
          },
          "right": {
            "type": "number",
            "format": "double"
          },
          "bottom": {
            "type": "number",
            "format": "double"
          },
          "left": {
            "type": "number",
            "format": "double"
          }
        }
      },
      "SaveTemplateRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "nullable": false
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "body": {
            "type": "string",
            "nullable": true
          },
          "payload": {
            "type": "string",
            "nullable": true
          },
          "page": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/PageOptionsRequest"
              }
            ]
          }
        }
      },
      "PageOptionsRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "size": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/PageSize"
              }
            ]
          },
          "orientation": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/PageOrientation"
              }
            ]
          },
          "margins": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/PageMarginsRequest"
              }
            ]
          }
        }
      },
      "PageMarginsRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "top": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "right": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "bottom": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "left": {
            "type": "number",
            "format": "double",
            "nullable": true
          }
        }
      },
      "ProblemDetails": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "default": "https://www.rfc-editor.org/rfc/rfc7231#section-6.5.1"
          },
          "title": {
            "type": "string",
            "default": "One or more validation errors occurred."
          },
          "status": {
            "type": "integer",
            "format": "int32",
            "default": 400
          },
          "instance": {
            "type": "string",
            "default": "/api/route"
          },
          "traceId": {
            "type": "string",
            "default": "0HMPNHL0JHL76:00000001"
          },
          "detail": {
            "type": "string",
            "nullable": true
          },
          "errors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProblemDetails_Error"
            }
          }
        }
      },
      "ProblemDetails_Error": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "default": "Error or field name"
          },
          "reason": {
            "type": "string",
            "default": "Error reason"
          },
          "code": {
            "type": "string",
            "nullable": true
          },
          "severity": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "RevisionSummary": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "version",
          "name",
          "createdAt",
          "updatedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "guid"
          },
          "version": {
            "type": "integer",
            "format": "int32"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ListTemplateRevisionsRequest": {
        "type": "object",
        "additionalProperties": false
      },
      "RenameTemplateRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "nullable": false
          }
        }
      },
      "TemplateSummary": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "name",
          "version",
          "createdAt",
          "updatedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "guid"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "version": {
            "type": "integer",
            "format": "int32"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "GetTemplateRequest": {
        "type": "object",
        "additionalProperties": false
      },
      "GenerateDocumentResponse": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "document"
        ],
        "properties": {
          "document": {
            "type": "string"
          },
          "warnings": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RenderWarning"
            }
          }
        }
      },
      "RenderWarning": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string"
          },
          "message": {
            "type": "string"
          },
          "location": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/SourceLocation"
              }
            ]
          }
        }
      },
      "SourceLocation": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "line": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "column": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "jsonPath": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "GenerateDocumentRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "payloadJson": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "DeleteTemplateRequest": {
        "type": "object",
        "additionalProperties": false
      },
      "CreateTemplateRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "nullable": false
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "body": {
            "type": "string",
            "nullable": true
          },
          "payload": {
            "type": "string",
            "nullable": true
          },
          "page": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/PageOptionsRequest"
              }
            ]
          }
        }
      },
      "RevisionDetail": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "version",
          "name",
          "createdAt",
          "updatedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "guid"
          },
          "version": {
            "type": "integer",
            "format": "int32"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "body": {
            "type": "string",
            "nullable": true
          },
          "payload": {
            "type": "string",
            "nullable": true
          },
          "page": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/PageOptionsDetail"
              }
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RenderDiagnosticsResponse": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "pageCount": {
            "type": "integer",
            "format": "int32"
          },
          "errors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RenderError"
            }
          },
          "warnings": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RenderWarning"
            }
          }
        }
      },
      "RenderError": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "$ref": "#/components/schemas/RenderErrorCode"
          },
          "message": {
            "type": "string"
          },
          "location": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/SourceLocation"
              }
            ]
          }
        }
      },
      "RenderErrorCode": {
        "type": "string",
        "description": "",
        "x-enumNames": [
          "JsonParse",
          "TemplateSyntax",
          "TemplateRuntime",
          "Render",
          "Timeout"
        ],
        "enum": [
          "JsonParse",
          "TemplateSyntax",
          "TemplateRuntime",
          "Render",
          "Timeout"
        ]
      },
      "RenderPreviewRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "templateHtml": {
            "type": "string",
            "nullable": true
          },
          "payloadJson": {
            "type": "string",
            "nullable": true
          },
          "page": {
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/PageOptionsRequest"
              }
            ]
          }
        }
      },
      "HealthResponse": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "buildNumber",
          "environment",
          "utcNow"
        ],
        "properties": {
          "status": {
            "type": "string"
          },
          "buildNumber": {
            "type": "string"
          },
          "environment": {
            "type": "string"
          },
          "utcNow": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ApiKeySummary": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "name",
          "prefix",
          "createdAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "guid"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "prefix": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "revokedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "CreatedApiKeyResponse": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "key",
          "token"
        ],
        "properties": {
          "key": {
            "$ref": "#/components/schemas/ApiKeySummary"
          },
          "token": {
            "type": "string"
          }
        }
      },
      "CreateApiKeyRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 128,
            "minLength": 0,
            "nullable": false
          },
          "description": {
            "type": "string",
            "maxLength": 2048,
            "minLength": 0,
            "nullable": true
          }
        }
      }
    },
    "securitySchemes": {
      "JWTBearerAuth": {
        "type": "http",
        "description": "Enter a JWT token to authorize the requests...",
        "scheme": "Bearer",
        "bearerFormat": "JWT"
      },
      "X-Api-Key": {
        "type": "apiKey",
        "description": "An API key minted in the studio, accepted wherever an access token is.",
        "name": "X-Api-Key",
        "in": "header"
      }
    }
  },
  "tags": [
    {
      "name": "Getting started",
      "description": "Stencilry turns a Liquid template and a JSON payload into a PDF. You author the template once, then\npost payloads against it.\n\nEverything below calls `https://api.stencilry.dev` and uses `$STENCILRY_KEY` for your API key.\n\n### 1. Get an API key\n\nTwo credentials open this API, and they are interchangeable — every secured operation accepts either:\n\n| Credential | Header | Who uses it |\n|---|---|---|\n| API key | `X-Api-Key: <token>` | Your server |\n| Access token | `Authorization: Bearer <jwt>` | The studio, in a browser |\n\n`POST /api-keys` needs a credential itself, so your first key comes from the studio: sign in, open\n**API keys**, and create one.\n\nThe token is returned **once**, by the call that creates the key. It is never readable again — the\nlist only carries `prefix`, the first 12 characters, so one key can be told from another. Store it\nbefore you close the dialog.\n\n### 2. Check that it works\n\n```\ncurl -s \"https://api.stencilry.dev/templates\" -H \"X-Api-Key: $STENCILRY_KEY\"\n```\n\nA `200` with a JSON array — `[]` on a new account — means the key is live:\n\n```json\n[\n  {\n    \"id\": \"0d6b9a5e-1f3c-4a7d-9c2b-5f8e1a0b7c34\",\n    \"name\": \"Invoice\",\n    \"description\": \"Monthly invoice\",\n    \"version\": 3,\n    \"createdAt\": \"2026-09-01T09:14:22.104Z\",\n    \"updatedAt\": \"2026-09-18T16:02:47.881Z\"\n  }\n]\n```\n\nA `401` means the header is missing or the key was revoked.\n\n### 3. Mint further keys from the API\n\nOnce you hold one key you can mint more without the studio:\n\n```\ncurl -s -X POST \"https://api.stencilry.dev/api-keys\" \\\n  -H \"X-Api-Key: $STENCILRY_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"billing-worker\",\"description\":\"Nightly invoice run\"}'\n```\n\n```json\n{\n  \"key\": {\n    \"id\": \"8f2c1b44-7a90-4d16-b3e8-2c5d90ab1f77\",\n    \"name\": \"billing-worker\",\n    \"description\": \"Nightly invoice run\",\n    \"prefix\": \"stcl_xvfb8Gx\",\n    \"createdAt\": \"2026-09-21T11:40:03.221Z\",\n    \"revokedAt\": null\n  },\n  \"token\": \"stcl_xvfb8GxK3mQ7pT2aL9dR4wN6yB1cE8sV\"\n}\n```\n\nNames are unique across the keys you hold that are not revoked — reusing a live name is a `409`.\nRevoking a key frees its name.\n\n### Next\n\n**Generate a PDF** posts a payload against a stored template. **Preview and diagnostics** renders\ntemplate and payload together without storing anything, which is the loop to use while authoring.\n",
      "x-traitTag": true
    },
    {
      "name": "Generate a PDF",
      "description": "A stored template plus a payload gives you a PDF. This is the call your server makes in production.\n\n### 1. Create the template\n\n```\ncurl -s -X POST \"https://api.stencilry.dev/templates\" \\\n  -H \"X-Api-Key: $STENCILRY_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"name\": \"Invoice\",\n        \"description\": \"Monthly invoice\",\n        \"body\": \"<h1>Invoice {{ number }}</h1><p>Due {{ due }}</p><p>Total {{ total }}</p>\",\n        \"payload\": \"{\\\"number\\\":\\\"INV-001\\\",\\\"due\\\":\\\"2026-10-01\\\",\\\"total\\\":\\\"R 1 240.00\\\"}\"\n      }'\n```\n\n`body` is the Liquid template. `payload` is a sample payload stored beside it — the studio uses it\nfor previews, and it documents the shape the template expects. Both are optional at creation and can\nbe filled in later with `PUT /templates/{id}`.\n\nThe response carries the template's `id`. Keep it; it is the only handle to the template.\n\n### 2. Generate\n\n```\ncurl -s -X POST \"https://api.stencilry.dev/templates/$TEMPLATE_ID/generate\" \\\n  -H \"X-Api-Key: $STENCILRY_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"payloadJson\":\"{\\\"number\\\":\\\"INV-042\\\",\\\"due\\\":\\\"2026-11-01\\\",\\\"total\\\":\\\"R 3 980.00\\\"}\"}'\n```\n\nNote that `payloadJson` is a **string** holding JSON, not a nested object. It is the payload the\ntemplate renders against.\n\n```json\n{\n  \"document\": \"JVBERi0xLjcKJcfsj6IKNSAwIG9iago8PC9MZW5ndGgg...\",\n  \"warnings\": []\n}\n```\n\n`document` is the PDF, base64. Decode it to bytes and write the file:\n\n```\ncurl -s -X POST \"https://api.stencilry.dev/templates/$TEMPLATE_ID/generate\" \\\n  -H \"X-Api-Key: $STENCILRY_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \"{\\\"payloadJson\\\":$(jq -Rs . < payload.json)}\" \\\n| jq -r .document | base64 -d > invoice.pdf\n```\n\n### 3. Read the warnings\n\n`warnings` is almost always empty and is never a reason to fail the call — the PDF in `document` is\ncomplete either way. A warning names something the renderer could not honour:\n\n```json\n{\n  \"document\": \"JVBERi0xLjcK...\",\n  \"warnings\": [\n    {\n      \"code\": \"template.undefined_field\",\n      \"message\": \"The template refers to 'customer.vatNumber', which the payload does not contain. It rendered as empty text.\",\n      \"location\": { \"line\": null, \"column\": null, \"jsonPath\": \"$.customer.vatNumber\" }\n    }\n  ]\n}\n```\n\nThe codes:\n\n| `code` | Means |\n|---|---|\n| `template.undefined_field` | The template read a field the payload does not contain. It rendered as empty text |\n| `render.asset_missing` | The template referenced an asset you have not stored. It rendered as nothing |\n| `render.asset_remote` | The template referenced a remote URL. Remote assets are never fetched — upload it and refer to it by name |\n| `render.asset_unreadable` | The stored asset is not the kind of file it claims to be. It rendered as nothing |\n| `render.asset_fetch_failed` | The asset exists but could not be read from storage this run |\n\n`location` is nullable, and so is each of its three fields.\n\nLog warnings.\n\n### Status codes\n\n| Code | Means |\n|---|---|\n| `200` | The PDF is in `document`. Check `warnings` |\n| `400` | `payloadJson` is not well-formed JSON, the template has no Liquid body, or the render failed |\n| `404` | No active template with that id. A deleted or archived template is a `404` |\n| `500` | The PDF rendered but the run could not be recorded, so it is withheld. Retry |\n| `503` | No render slot came free. Retry with backoff |\n\n**Errors and retries** covers `500` and `503` in full.\n",
      "x-traitTag": true
    },
    {
      "name": "Preview and diagnostics",
      "description": "`POST /render/preview` renders a template and a payload together and stores nothing. Use it while\nauthoring: no template has to exist, and nothing you send is kept.\n\n### The call\n\n```\ncurl -s -X POST \"https://api.stencilry.dev/render/preview\" \\\n  -H \"X-Api-Key: $STENCILRY_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"templateHtml\": \"<h1>Invoice {{ number }}</h1><p>Total {{ total }}</p>\",\n        \"payloadJson\": \"{\\\"number\\\":\\\"INV-042\\\",\\\"total\\\":\\\"R 3 980.00\\\"}\",\n        \"page\": { \"size\": \"A4\", \"orientation\": \"Portrait\" }\n      }' \\\n  -o preview.multipart -D headers.txt\n```\n\n`page` is optional and defaults to A4 portrait.\n\n### The success body is multipart, not JSON\n\nA `200` is `multipart/form-data` with two parts:\n\n| Part | Content type | Holds |\n|---|---|---|\n| `diagnostics` | `application/json` | Page count, errors, warnings |\n| `pdf` | `application/pdf` | The raw bytes, filename `preview.pdf` |\n\nMultipart rather than a base64 envelope because base64 adds a third to the size of every render, and\nan authoring loop renders constantly. The boundary is generated per response and is in the\n`Content-Type` header — read it from there, never assume it.\n\nThe parts arrive in that order. The sample beside this operation shows the read in each language.\n\n### A failed render is 422, not 400\n\n| Code | Body | Means |\n|---|---|---|\n| `200` | multipart | It rendered. Read `diagnostics.warnings` anyway |\n| `400` | problem details | The request envelope itself could not be bound |\n| `422` | diagnostics JSON | The request was read; the template or the payload is wrong |\n| `503` | diagnostics JSON | No render slot came free. Nothing was attempted |\n\n`422` is the one to handle, and it is wider than it looks: a `payloadJson` that is not well-formed\nJSON lands here too, not on `400`. This differs from `POST /templates/{id}/generate`, where malformed\n`payloadJson` is a `400`.\n\nThe body is the same diagnostics object, without a PDF:\n\n```json\n{\n  \"pageCount\": 0,\n  \"errors\": [\n    {\n      \"code\": \"TemplateSyntax\",\n      \"message\": \"End of tag '}}' was expected at (1:14)\",\n      \"location\": { \"line\": 1, \"column\": 14, \"jsonPath\": null }\n    }\n  ],\n  \"warnings\": []\n}\n```\n\n`code` is one of `JsonParse`, `TemplateSyntax`, `TemplateRuntime`, `Render`, `Timeout`. They cross\nthe wire as names, never as numbers. Malformed `payloadJson` gives `JsonParse`.\n\n### A clean render is not a correct document\n\nThe renderer reports nothing when it ignores something — no exception, no warning, no log entry. CSS\nit does not support is parsed and then dropped, and the page comes back looking finished.\n\nSo `200` with `errors: []` and `warnings: []` means the render completed. It does not mean the\ndocument is right. Open the PDF and look at the pages before you ship a template.\n",
      "x-traitTag": true
    },
    {
      "name": "Errors and retries",
      "description": "Every failure that carries a body uses RFC 9457 problem details, served as\n`application/problem+json`. The two render endpoints are the exception on some codes, and return\ndiagnostics instead — those are listed below.\n\n### The problem shape\n\n`POST /templates` with no `name`:\n\n```json\n{\n  \"type\": \"https://www.rfc-editor.org/rfc/rfc7231#section-6.5.1\",\n  \"title\": \"Bad Request\",\n  \"status\": 400,\n  \"instance\": \"/templates\",\n  \"traceId\": \"0HNONU92PQJJV:00000001\",\n  \"detail\": \"A name is required.\",\n  \"errors\": [\n    { \"name\": \"name\", \"reason\": \"A name is required.\" }\n  ]\n}\n```\n\n`errors[].name` is the request field that caused it, or `generalErrors` when the failure is not about\na field — a `404` reads `{\"name\":\"generalErrors\",\"reason\":\"No Template exists with that id.\"}`.\n\nQuote `traceId` when you report a problem. It identifies the single request in our logs.\n\n### What to do with each code\n\n| Code | Retry? | Do |\n|---|---|---|\n| `400` | No | Fix the request. `errors` names the field |\n| `401` | No | The credential is missing, malformed or revoked. Mint a new key |\n| `404` | No | The template id does not exist, or is archived or deleted |\n| `409` | No | An API key with that name already exists and is not revoked |\n| `422` | No | The render ran and the template or payload is wrong. Read the diagnostics |\n| `500` | **Yes** | Retry — see below |\n| `503` | **Yes** | No render slot came free. Retry with backoff |\n\n### 500 is retryable here, and that is deliberate\n\nOn `POST /templates/{id}/generate` and `POST /render/preview`, a `500` has a specific meaning: **the\nPDF rendered, and the service could not record the run.** The document is withheld rather than\nreturned unrecorded.\n\nNothing was charged and nothing was kept. Retry the identical request.\n\n### 503 and backoff\n\nRenders run in a fixed number of slots. When none comes free within the wait, the request is refused\nwithout being attempted — `503`, with a diagnostics body whose `errors` is empty and `pageCount` is\n`0`. Nothing rendered.\n\nRetry with exponential backoff and jitter. Three attempts at 1s, 2s and 4s, each ±20%, clears\nordinary contention:\n\n```\nfor attempt in 1 2 3; do\n  code=$(curl -s -o out.json -w '%{http_code}' -X POST \"https://api.stencilry.dev/templates/$TEMPLATE_ID/generate\" \\\n    -H \"X-Api-Key: $STENCILRY_KEY\" -H \"Content-Type: application/json\" \\\n    -d \"{\\\"payloadJson\\\":$(jq -Rs . < payload.json)}\")\n\n  [ \"$code\" = \"200\" ] && break\n  [ \"$code\" = \"500\" ] || [ \"$code\" = \"503\" ] || break\n\n  sleep $(( 2 ** (attempt - 1) ))\ndone\n```\n\n### What never to retry\n\nA `400`, `401`, `404`, `409` or `422` will fail identically however many times you send it.\n\n### Idempotency\n\n`POST /templates/{id}/generate` and `POST /render/preview` have no side effect you can observe beyond\na recorded run, so a retry is safe. `POST /templates` and `POST /api-keys` create things — a blind\nretry after a timeout can leave you with two. Check with `GET /templates` or `GET /api-keys` before\nsending again.\n",
      "x-traitTag": true
    },
    {
      "name": "Templates"
    },
    {
      "name": "Rendering"
    },
    {
      "name": "API keys"
    },
    {
      "name": "Capabilities"
    },
    {
      "name": "Diagnostics"
    }
  ],
  "x-tagGroups": [
    {
      "name": "Guides",
      "tags": [
        "Getting started",
        "Generate a PDF",
        "Preview and diagnostics",
        "Errors and retries"
      ]
    },
    {
      "name": "API reference",
      "tags": [
        "Templates",
        "Rendering",
        "API keys",
        "Capabilities",
        "Diagnostics"
      ]
    }
  ]
}