{
  "openapi": "3.1.0",
  "info": {
    "title": "ChartPNG API",
    "version": "1.0.0",
    "summary": "Chart.js configuration in, PNG image out.",
    "description": "Send a Chart.js configuration as plain JSON and get a PNG back. Without a valid key the image carries a small watermark. Docs: https://chartpng.dev/docs"
  },
  "servers": [{ "url": "https://chartpng.dev" }],
  "paths": {
    "/chart": {
      "get": {
        "operationId": "getChart",
        "summary": "Render a chart from query parameters",
        "description": "Unknown or repeated parameters are refused, so a URL cannot be varied to bypass the cache. The QuickChart names listed under x-aliases are accepted for the same fields. For paid images in public places, sign the URL with k and s; never put the key in a URL.",
        "parameters": [
          {
            "name": "c",
            "in": "query",
            "required": true,
            "description": "Chart.js configuration as URL-encoded JSON, at most 16 KB, checked after any repair. Typical near-JSON from language models is repaired and the repairs are listed in the X-ChartPNG-Fixes response header; functions written as text are removed, never run. type is one of bar, line, pie, doughnut, radar, polarArea, scatter, bubble. Report presets are set in options.plugins.chartpng.preset: kpi, ranking, comparison, funnel or gauge. Their extra options sit beside it: previous (number, kpi only), max (number above 0, gauge only, default 100), prefix and suffix (strings around the kpi number, the ranking, funnel and gauge values; comparison does not use them), values (false hides the numbers a preset draws). funnel needs type bar and one dataset with a value per stage; gauge needs type doughnut and one dataset whose first number is the value. Anything the config sets wins over the preset. options.plugins.chartpng.theme is false, default, editorial, mono, colorblind, vivid or ocean; any other name is a 400. options.plugins.chartpng.palette is a list of 1 to 12 hex colours that replaces the theme's series colours. options.plugins.chartpng.preset also takes the output presets email and slack, alone or with a report preset as 'ranking,email'. The query parameters theme, palette and preset are the same settings; the config wins when both set one. Any other preset value is a 400 whose error message lists the valid presets.",
            "schema": { "type": "string" },
            "x-aliases": ["chart"]
          },
          {
            "name": "w",
            "in": "query",
            "required": false,
            "description": "Width in pixels.",
            "schema": { "type": "integer", "minimum": 50, "maximum": 2000, "default": 600 },
            "x-aliases": ["width"]
          },
          {
            "name": "h",
            "in": "query",
            "required": false,
            "description": "Height in pixels.",
            "schema": { "type": "integer", "minimum": 50, "maximum": 2000, "default": 360 },
            "x-aliases": ["height"]
          },
          {
            "name": "bkg",
            "in": "query",
            "required": false,
            "description": "Background colour, any CSS colour, at most 32 characters. A longer value falls back to white.",
            "schema": { "type": "string", "maxLength": 32, "default": "#ffffff" },
            "x-aliases": ["backgroundColor"]
          },
          {
            "name": "dpr",
            "in": "query",
            "required": false,
            "description": "Device pixel ratio. 2 gives twice the pixels for retina screens. The free plan renders up to 4 megapixels (w x h x dpr squared).",
            "schema": { "type": "integer", "enum": [1, 2], "default": 1 },
            "x-aliases": ["devicePixelRatio"]
          },
          {
            "name": "k",
            "in": "query",
            "required": false,
            "description": "Key id for a signed URL: the first 12 hex characters of the SHA-256 of the key. Send it with s.",
            "schema": { "type": "string" }
          },
          {
            "name": "s",
            "in": "query",
            "required": false,
            "description": "Signature for a signed URL: the first 32 hex characters of an HMAC-SHA256 over [c, w, h, bkg, dpr] as they appear in the URL, with [theme, palette, preset] added after them when the URL has any of the three. Changing any of them invalidates it. Send it with k.",
            "schema": { "type": "string" }
          },
          {
            "name": "theme",
            "in": "query",
            "required": false,
            "description": "Named theme: default, editorial, mono, colorblind, vivid or ocean; false turns the ChartPNG look off. The same as options.plugins.chartpng.theme, which wins when the config sets it. Light or dark colours follow bkg. Any other name is a 400.",
            "schema": { "type": "string", "enum": ["default", "editorial", "mono", "colorblind", "vivid", "ocean", "false"], "default": "default" }
          },
          {
            "name": "palette",
            "in": "query",
            "required": false,
            "description": "Series colours, comma-separated hex: 1f5fad,e0712c,13896f. The # is optional (in a URL it must be written %23). 1 to 12 colours, #rgb or #rrggbb; anything else is a 400. Replaces the theme's colours for datasets that set none. The same as options.plugins.chartpng.palette, which wins when the config sets it.",
            "schema": { "type": "string", "pattern": "^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})(,#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})){0,11}$" }
          },
          {
            "name": "preset",
            "in": "query",
            "required": false,
            "description": "Report preset (kpi, ranking, comparison) and/or output preset (email, slack), at most one of each, comma-separated: ranking,email. An output preset sets w, h, dpr and bkg when the request leaves them out (email 600 x 360 at dpr 2, slack 800 x 400 at dpr 2; a transparent bkg becomes white), gives the image roomier margins and keeps all type at 11 px or more. The same as options.plugins.chartpng.preset; for each of the two kinds the config wins when it sets one.",
            "schema": { "type": "string", "examples": ["email", "slack", "ranking,email"] }
          }
        ],
        "security": [{}, { "bearerAuth": [] }],
        "responses": {
          "200": { "$ref": "#/components/responses/Png" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "post": {
        "operationId": "postChart",
        "summary": "Render a chart from a JSON body",
        "description": "The same fields as the query, in a JSON body of at most 20 KB. Use it for large configs and for server-side calls with a Bearer key. Names in the body are not aliased and unknown fields are ignored. A request with a Bearer header is answered with Cache-Control: private, no-store.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ChartRequest" }
            }
          }
        },
        "security": [{}, { "bearerAuth": [] }],
        "responses": {
          "200": { "$ref": "#/components/responses/Png" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "413": {
            "description": "The request body is larger than 20 KB.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/chart.json": {
      "post": {
        "operationId": "postChartJson",
        "summary": "Get the URL, alt text and snippets for a chart without drawing it",
        "description": "The same body as POST /chart, validated the same way with the same limits and errors. Nothing is drawn. The url is the free plan's GET URL (watermarked, no key or signature) and carries the config as repaired, with theme, palette and preset as query parameters when the body sent them, so a GET of it gives the same PNG as POST /chart with this body. The config is drawn once to check it, so errors that only appear when drawing (unknown theme or preset, numbers too large) are returned here. A url longer than 14000 characters is refused with a 400, because Vercel's CDN answers 414 above 14 KB; the url is about twice the size of the config, so use POST /chart for configs over roughly 6 KB. Slack limits an image block's image_url to 3000 characters. The alt is built only from literal values in the data. Bearer keys are ignored.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ChartRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The URL and the text to paste the image.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChartJson"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "413": {
            "description": "The request body is larger than 20 KB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/c": {
      "post": {
        "operationId": "postShortUrl",
        "summary": "Store a chart for 90 days and get a short image URL",
        "description": "The same body as POST /chart, validated the same way with the same limits and errors; the config is stored as repaired (X-ChartPNG-Fixes lists the repairs) and drawn once to check it. Answers with https://chartpng.dev/c/{id}.png. The id is derived from a hash of the stored request and the key, so the same chart gives the same id, and posting it again keeps it another 90 days. With Authorization: Bearer and a Pro key, the image has no watermark while the key stays paid; signed fields (k, s) are ignored. Caps: 200 creations a month per address on the free plan, 5000 per Pro key, plus a daily storage budget shared by free short URLs.",
        "security": [{}, { "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ChartRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The short URL and the text to paste the image.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ShortUrl" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "413": {
            "description": "The request body is larger than 20 KB.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": {
            "description": "The monthly cap of short URLs for this address or key is reached, or free short URLs are used up for the day.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "500": { "$ref": "#/components/responses/ServerError" },
          "503": {
            "description": "Storage for short URLs cannot be reached. /chart keeps working.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/c/{id}.png": {
      "get": {
        "operationId": "getShortUrl",
        "summary": "Render a chart stored by POST /c",
        "description": "Draws the stored request exactly as GET /chart would, with the watermark unless it was made with a Pro key that is still paid. Cached like /chart until the short URL expires. Takes no query parameters.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The id returned by POST /c: 11 letters and digits.",
            "schema": { "type": "string", "pattern": "^[0-9A-Za-z]{11}$" }
          }
        ],
        "responses": {
          "200": { "$ref": "#/components/responses/Png" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": {
            "description": "No short URL with this id, or it expired. The site's HTML 404 page.",
            "content": { "text/html": { "schema": { "type": "string" } } }
          },
          "500": { "$ref": "#/components/responses/ServerError" },
          "503": {
            "description": "Storage for short URLs cannot be reached. /chart keeps working.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A Pro key, for server-side calls only. A missing or unknown key is not an error: the image is rendered with a watermark."
      }
    },
    "schemas": {
      "ChartRequest": {
        "type": "object",
        "required": ["c"],
        "properties": {
          "c": {
            "description": "The Chart.js configuration, as an object or as a JSON string. At most 16 KB either way, checked after any repair. A string may be near-JSON; see X-ChartPNG-Fixes. Report presets: options.plugins.chartpng.preset is kpi, ranking, comparison, funnel or gauge, with previous (number, kpi only), max (number above 0, gauge only), prefix and suffix (strings around the kpi number and the ranking, funnel and gauge values; comparison does not use them).",
            "type": ["object", "string"]
          },
          "w": { "type": ["integer", "string"], "description": "Width in pixels, 50 to 2000. Default 600." },
          "h": { "type": ["integer", "string"], "description": "Height in pixels, 50 to 2000. Default 360." },
          "bkg": { "type": "string", "maxLength": 32, "description": "Background colour. Default #ffffff." },
          "dpr": { "type": ["integer", "string"], "description": "1 or 2. Default 1." },
          "k": { "type": "string", "description": "Key id of a signed request. Send it with s." },
          "s": { "type": "string", "description": "Signature of a signed request. Send it with k." },
          "theme": { "type": ["string", "boolean"], "description": "Named theme, or false for no theme. See the theme query parameter." },
          "palette": { "type": ["string", "array"], "items": { "type": "string" }, "description": "Series colours as hex: a comma-separated string or a list of 1 to 12. See the palette query parameter." },
          "preset": { "type": ["string", "array"], "items": { "type": "string" }, "description": "Report and/or output preset: 'ranking', 'email', 'ranking,email' or a list. See the preset query parameter." }
        }
      },
      "ChartJson": {
        "type": "object",
        "required": [
          "url",
          "alt",
          "markdown",
          "html",
          "fixes"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "Public GET URL of the free plan, with the watermark. No key or signature."
          },
          "alt": {
            "type": "string",
            "maxLength": 2000,
            "description": "Deterministic text from literal values in the data: type, title, number of series, highest value with its label, last value and, for line charts, the change from first to last."
          },
          "markdown": {
            "type": "string",
            "description": "![alt](url), with brackets in the alt escaped."
          },
          "html": {
            "type": "string",
            "description": "An img tag with src, alt, width and height. Quotes and < in the alt are escaped. Width and height are CSS sizes, without dpr."
          },
          "fixes": {
            "type": "array",
            "items": { "type": "string" },
            "description": "The codes of the repairs applied to the config, in the order of the X-ChartPNG-Fixes header of GET /chart (trailing_comma, code_fence, single_quotes, unquoted_keys, double_encoded, function_string, missing_type, v2_scales, v2_plugins, v2_datasets, v2_defaults). An empty list when nothing was repaired."
          }
        }
      },
      "ShortUrl": {
        "type": "object",
        "required": ["id", "url", "expires", "alt", "markdown", "html"],
        "properties": {
          "id": { "type": "string", "pattern": "^[0-9A-Za-z]{11}$", "description": "The short URL's id." },
          "url": { "type": "string", "description": "https://chartpng.dev/c/{id}.png" },
          "expires": { "type": "string", "format": "date-time", "description": "When the short URL stops working, 90 days after this request." },
          "alt": { "type": "string", "maxLength": 2000, "description": "The same alt text as POST /chart.json." },
          "markdown": { "type": "string", "description": "![alt](url), with brackets in the alt escaped." },
          "html": { "type": "string", "description": "An img tag with src, alt, width and height." }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": { "type": "string", "description": "What was wrong, in one sentence." }
        }
      }
    },
    "responses": {
      "Png": {
        "description": "The chart as a PNG image.",
        "headers": {
          "X-ChartPNG-Fixes": {
            "description": "Comma-separated codes of the repairs applied to the config: trailing_comma, code_fence, single_quotes, unquoted_keys, double_encoded, function_string, missing_type, v2_scales, v2_plugins, v2_datasets, v2_defaults. Absent when nothing was repaired. Exposed to browsers with Access-Control-Expose-Headers.",
            "schema": { "type": "string" }
          },
          "X-Chart-Alt": {
            "description": "The alt text for the chart, the same as the alt of POST /chart.json, percent-encoded for a header: read it with decodeURIComponent. Exposed to browsers with Access-Control-Expose-Headers.",
            "schema": {
              "type": "string"
            }
          },
          "Cache-Control": {
            "description": "Public and immutable for requests without a Bearer header, private, no-store with one.",
            "schema": { "type": "string" }
          }
        },
        "content": { "image/png": {} }
      },
      "BadRequest": {
        "description": "The request was refused: unknown or repeated parameter, JSON that could not be repaired, a size outside the limits, a chart over a free-plan limit, an unknown preset or one that does not fit the config, an unknown theme, a palette that is not 1 to 12 hex colours, or an option with a shape Chart.js could not draw with (a value that is not an object where it needs one, such as options.plugins.legend or options.scales.x.ticks). The body is an object with only an error message; bad_preset, bad_theme, bad_palette and bad_option are only the internal reasons these errors are counted under.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "ServerError": {
        "description": "The chart could not be drawn.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      }
    }
  }
}
