{
  "openapi": "3.1.0",
  "info": {
    "title": "Squiggler Synthetic ECG API",
    "version": "1",
    "description": "Synthetic ECG tracings for education, simulation and illustration only: generated, never a recording of a person, not for clinical use. Write a spec (a JSON object naming at least a rhythm), check what it would draw, then render it or get a studio link. Read GET /v1/ecg/guide once first: it lists every field. Reference: https://www.squiggler.io/apidocs/"
  },
  "servers": [{ "url": "https://squiggler-api.fly.dev" }],
  "security": [{ "apiKey": [] }],
  "paths": {
    "/v1/ecg/guide": {
      "get": {
        "operationId": "getGuide",
        "summary": "How to write a spec, as Markdown",
        "description": "Every field with its unit and range, which leads each ST and T change moves, ready-made specs and the rules that are easy to get wrong. Read once per session, before writing a spec.",
        "responses": {
          "200": { "description": "The guide.", "content": { "text/markdown": { "schema": { "type": "string" } } } },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/ecg/vocabulary": {
      "get": {
        "operationId": "getVocabulary",
        "summary": "Everything the API accepts",
        "description": "Rhythms, findings, layouts, every spec field (fields), ready specs (recipes) and the numeric bounds. Build specs from this rather than from memory.",
        "responses": {
          "200": { "description": "The vocabulary.", "content": { "application/json": { "schema": { "type": "object" } } } },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/ecg/check": {
      "post": {
        "operationId": "checkSpec",
        "summary": "What a spec would draw, as JSON",
        "description": "Dry run: the rhythm's name, rates and intervals counted off the drawn beats, the ST level in every lead, and plausibility advisories. Use it to confirm a spec before rendering. A 400 lists each problem with a hint.",
        "requestBody": { "$ref": "#/components/requestBodies/Spec" },
        "responses": {
          "200": { "description": "What would be drawn.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CheckResult" } } } },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/ecg/link": {
      "post": {
        "operationId": "studioLink",
        "summary": "A studio link that opens the tracing",
        "description": "Checks the spec, then returns a www.squiggler.io studio URL with the whole tracing in its fragment (nothing is stored). Give this URL to the user when you cannot show them an image.",
        "requestBody": { "$ref": "#/components/requestBodies/Spec" },
        "responses": {
          "200": { "description": "The link.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LinkResult" } } } },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/ecg/render": {
      "post": {
        "operationId": "renderEcg",
        "summary": "An image of the tracing",
        "description": "PNG by default, SVG with the header accept: image/svg+xml. The body is binary: a caller that only reads JSON should use studioLink instead. Measurements come back as x-ecg-* headers. On a pay-as-you-go key each unique request body per billing month is one billed ECG (500 included, then EUR 0.15 each); the same body again is free, and a refused render is not billed.",
        "requestBody": { "$ref": "#/components/requestBodies/Spec" },
        "responses": {
          "200": {
            "description": "The tracing.",
            "headers": {
              "x-squiggler-ecg": { "description": "Pay-as-you-go keys: new (a unique ECG this billing month) or repeat (free).", "schema": { "type": "string", "enum": ["new", "repeat"] } },
              "x-squiggler-ecgs-this-period": { "description": "Pay-as-you-go keys: unique ECGs on the subscription so far this billing month.", "schema": { "type": "integer" } }
            },
            "content": {
              "image/png": { "schema": { "type": "string", "format": "binary" } },
              "image/svg+xml": { "schema": { "type": "string" } }
            }
          },
          "402": {
            "description": "Pay-as-you-go keys only: the monthly spending cap is reached (reason spend_cap_reached) or the subscription is not paid up (reason subscription_inactive). Bodies already drawn this month still render. Not retryable.",
            "content": { "application/json": { "schema": { "type": "object", "properties": {
              "error": { "type": "string" }, "detail": { "type": "string" },
              "reason": { "type": "string", "enum": ["spend_cap_reached", "subscription_inactive"] },
              "uniqueEcgs": { "type": "integer" }, "limit": { "type": "integer" }, "capEuros": { "type": "number" },
              "retryable": { "type": "boolean" }
            } } } }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/ecg/signal": {
      "post": {
        "operationId": "exportSignal",
        "summary": "The tracing as samples",
        "description": "Millivolts per lead at 500 or 250 Hz, with each beat's timing, as JSON or CSV. For software that draws its own trace. Options go in a signal object beside the spec.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignalRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The samples.",
            "content": {
              "application/json": { "schema": { "type": "object" } },
              "text/csv": { "schema": { "type": "string" } }
            }
          },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/sandbox/ecg/render": {
      "post": {
        "operationId": "renderEcgSandbox",
        "summary": "A watermarked trial image of the tracing",
        "description": "For sandbox keys; a production key works here too. The same body as renderEcg, answered as PNG only, at no more than 4 px/mm, with SQUIGGLER SANDBOX on every lane and the Squiggler mark in the corner. imprint, attribution* and finish fields are ignored.",
        "requestBody": { "$ref": "#/components/requestBodies/Spec" },
        "responses": {
          "200": { "description": "The watermarked tracing.", "content": { "image/png": { "schema": { "type": "string", "format": "binary" } } } },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/sandbox/ecg/vocabulary": {
      "get": {
        "operationId": "getVocabularySandbox",
        "summary": "Everything the API accepts (sandbox base)",
        "description": "The same answer as getVocabulary, for sandbox keys.",
        "responses": {
          "200": { "description": "The vocabulary.", "content": { "application/json": { "schema": { "type": "object" } } } },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/v1/sandbox/ecg/guide": {
      "get": {
        "operationId": "getGuideSandbox",
        "summary": "How to write a spec, as Markdown (sandbox base)",
        "description": "The same answer as getGuide, for sandbox keys.",
        "responses": {
          "200": { "description": "The guide.", "content": { "text/markdown": { "schema": { "type": "string" } } } },
          "default": { "$ref": "#/components/responses/Error" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": { "type": "apiKey", "in": "header", "name": "x-api-key", "description": "A key issued by Squiggler (sqk_…). Keep it on a server or in the tool's secret store, never in a prompt." }
    },
    "requestBodies": {
      "Spec": {
        "required": true,
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Spec" } } }
      }
    },
    "schemas": {
      "Spec": {
        "type": "object",
        "description": "A rhythm spec, plus optional paper fields. Only rhythm is required. Every other field, its unit, range and meaning is listed in GET /v1/ecg/vocabulary (fields) and GET /v1/ecg/guide; unknown fields are refused with a 400 rather than ignored. Start from a recipe in vocabulary.recipes.",
        "required": ["rhythm"],
        "properties": {
          "rhythm": { "type": "string", "description": "A rhythm id from vocabulary.rhythms, e.g. sinus." },
          "seed": { "type": "string", "maxLength": 200, "description": "Pins this recording (artefact, beat timing). Same spec and seeds give the same tracing." },
          "patientSeed": { "type": "string", "maxLength": 200, "description": "Pins whose heart it is (anatomy, wave shapes)." },
          "theme": { "type": "string", "description": "Paper style, from vocabulary.themes." },
          "pixelsPerMm": { "type": "number", "minimum": 1, "maximum": 12, "description": "PNG density. Render only." }
        },
        "additionalProperties": true
      },
      "SignalRequest": {
        "allOf": [
          { "$ref": "#/components/schemas/Spec" },
          {
            "type": "object",
            "properties": {
              "signal": {
                "type": "object",
                "description": "Export options; the legal values are in vocabulary.signal.",
                "properties": {
                  "leads": { "type": "array", "items": { "type": "string" } },
                  "sampleRateHz": { "type": "integer", "enum": [500, 250] },
                  "artifact": { "type": "string", "enum": ["recording", "clean"] },
                  "window": { "type": "string", "enum": ["strip", "cycle"] },
                  "format": { "type": "string", "enum": ["json", "csv"] }
                },
                "additionalProperties": false
              }
            }
          }
        ]
      },
      "CheckResult": {
        "type": "object",
        "properties": {
          "ok": { "type": "boolean" },
          "label": { "type": "string", "description": "The rhythm's name as drawn." },
          "advisories": { "type": "array", "items": { "type": "string" } },
          "measured": { "type": "object", "description": "Rates, beat kinds, the dials as drawn, and per-lead ST levels in millimetres." },
          "renderVersion": { "type": "string" }
        }
      },
      "LinkResult": {
        "type": "object",
        "properties": {
          "url": { "type": "string", "description": "A studio URL that opens this tracing." },
          "label": { "type": "string" },
          "advisories": { "type": "array", "items": { "type": "string" } },
          "renderVersion": { "type": "string" }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": { "type": "string" },
          "detail": { "type": "string" },
          "retryable": { "type": "boolean" },
          "fields": { "type": "array", "items": { "type": "string" } },
          "issues": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "path": { "type": "string" },
                "message": { "type": "string" },
                "fields": { "type": "array", "items": { "type": "string" } },
                "hint": { "type": "string" }
              }
            }
          },
          "code": { "type": "string" }
        }
      }
    },
    "responses": {
      "Error": {
        "description": "A refusal. 400 lists issues with hints; 401 is a missing or invalid key; 429 is the rate limit (see Retry-After); 502 and 503 may be retried when retryable is true.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  }
}
