Developers Open the studio

HTTP API · v1

Synthetic ECG API

Describe a heart rhythm as JSON. Returns an image of the ECG paper, the signal as samples, or a machine-readable report. Nothing is stored. The same request always returns the same output.

01 Overview

Turns a JSON description of a heart rhythm into an image of the ECG paper. Send a rhythm with its findings, intervals and ST changes. Returns a PNG or SVG. Nothing is stored. The same request always returns the same image.

For education, simulation and illustration only. Not a medical device, and not for clinical use.

Every tracing is synthetic. None is a recording of a person.

Six endpoints under /v1/ecg: a vocabulary, a render, a signal export, a check, a guide and a studio link. Ready-made setups for Claude and OpenAI assistants are in section 9.

Where the API fits in an automated case generation pipeline: Case generation.

Authenticated with an API key in the x-api-key header. Keys are prefixed sqk_ and issued by Squiggler (inquiry@squiggler.io). The key carries the rate allowance and belongs on a server. Never expose it in a browser, a mobile app or an agent’s prompt.

Agent discovery: www.squiggler.io/llms.txt.

02 Getting started

A base URL and an API key.

export ECG_API="https://squiggler-api.fly.dev"
export ECG_KEY="sqk_…"

Send the key on every request:

x-api-key: sqk_…

Successful responses carry x-ecg-key-label: the name the key was issued under (non-ASCII characters replaced by ?). Useful for distinguishing keys when one is used per environment.

A normal sinus rhythm, as a PNG:

curl -X POST "$ECG_API/v1/ecg/render" \
  -H "x-api-key: $ECG_KEY" \
  -H "content-type: application/json" \
  -d '{"rhythm":"sinus"}' \
  -o ecg.png

A twelve-lead with inferior ST elevation, as SVG:

curl -X POST "$ECG_API/v1/ecg/render" \
  -H "x-api-key: $ECG_KEY" \
  -H "content-type: application/json" \
  -H "accept: image/svg+xml" \
  -d '{"rhythm":"sinus","format":"twelve_lead","stFoci":[{"epicenter":"inferior","mm":4}]}' \
  -o ecg.svg

Trying it first: the sandbox

A sandbox key lets you try the API before buying a production key. It works under a different base path, /v1/sandbox/ecg, with the same request bodies, the same validation and the same errors, so moving to production is a change of base path and key.

curl -X POST "$ECG_API/v1/sandbox/ecg/render" \
  -H "x-api-key: $ECG_SANDBOX_KEY" \
  -H "content-type: application/json" \
  -d '{"rhythm":"sinus","format":"twelve_lead"}' \
  -o ecg.png

Three routes, and what is different about them:

  • POST /v1/sandbox/ecg/render: PNG only, whatever Accept says. Drawn at no more than 4 pixels per millimetre (a lower pixelsPerMm is kept). Every lane carries the imprint SQUIGGLER SANDBOX, and the corner carries the Squiggler mark and a "not for production use" line. Any imprint, attribution* or finish field you send is ignored.
  • GET /v1/sandbox/ecg/vocabulary and GET /v1/sandbox/ecg/guide: the same as in production.

Checking, signal export and studio links are production only. Responses carry x-squiggler-sandbox. A sandbox key used on /v1/ecg answers 403 with "error": "This is a sandbox key"; a production key also works in the sandbox. Sandbox keys run on their own, slower lane (10 requests a minute unless agreed otherwise) and usually expire after 30 days. To get one, contact Squiggler.

03 Endpoints

GET /v1/ecg/vocabulary Every rhythm, finding, layout and numeric range the service accepts, and what each field means.
POST /v1/ecg/render An image of the tracing described by the request body.
POST /v1/ecg/check What a spec would draw, as JSON. No image. See section 6.
POST /v1/ecg/signal The tracing as numbers: samples per lead in millivolts, with each beat’s timing. JSON or CSV. See section 7.
POST /v1/ecg/link A studio link that opens the tracing, for an assistant that cannot show an image. See section 9.
GET /v1/ecg/guide How to write a spec, as Markdown. See section 9.

Vocabulary

Returns every value the deployment accepts. Build forms and validation from this response; do not hardcode its lists, which grow.

curl -H "x-api-key: $ECG_KEY" "$ECG_API/v1/ecg/vocabulary"

The response is a JSON object. The keys most likely needed:

KeyContents
rhythmsEach rhythm id, its display label, a plausible rate band, which cadences it supports, and whether it can carry a pacemaker (paceable, paceModes).
patterns, patternGroupsNamed findings (LVH, WPW, Brugada, Wellens, P pulmonale, P mitrale and so on), grouped for a menu. Each entry carries id, label, group, blurb, needsTwelveLead and leadScoped.
formats, leads, leadLayoutsRhythm strip or twelve-lead, which lead, which arrangement. Four lead layouts: standard (international), cabrera (the Nordic order), cabrera_split (three sheets) and standard_6x1 (six leads per sheet over two sheets).
leadLayoutPagesHow many sheets each layout produces. Read this rather than assuming which layouts are paged.
leadLayoutSpeedFactorsPer-sheet speed factors for each layout. A cabrera_split detail sheet runs at twice the recording’s speed ([1, 2, 2]); standard_6x1 is [1, 1].
paperSpeedsThe legal values for paperSpeedMmPerSec: [25, 50].
paperSpeedDefaultsDefault paper speed per format and layout.
durationDefaultsDefault recording length per format, layout and paper speed. Range is 3–30 s.
territories, combinableTerritoriesWhere an ST shift, T change or old infarct sits, and which walls may be combined.
conductionBundle branch block options.
intervalsPR, QRS and QT bands with the clinical thresholds marked.
rangesThe numeric bounds of every field the spec accepts, by name. Most entries are {min, max}; the artefact dials are nested under noise; patternsMax, fociMax and seedMaxLength are bare numbers.
boundsThe same bounds as a flat list, one entry per bound, each with a dotted path and a kind (band, count, choice or flag). Use this when generating a schema by walking the response.
themesPaper styles: paper, red, orange, blue, mono, monitor, bedside.
finishesScanned and photographed looks: each entry has id, label, blurb and themes (the paper themes it may be applied to). Absent on an engine that draws only the clean sheet.
imageThe content types a render can answer with (contentTypes: ["image/png","image/svg+xml"]) and the pixelsPerMm band (1–12, default 4).
laneHeightMm, lanesThe bounds and defaults of those two envelope fields.
pacingModesEach pacing mode, what it writes onto the spec (writes), and which field carries the device’s rate (rateField).
transitionsCapability and limits for rhythm changes on one recording: max (the most changes, currently 9), scoped (which fields a section may carry) and defibAtSec.
fociGeometry for stFoci, tFoci and qFoci: epicenters, spread anchors, frontal-plane projection, chest-lead shares, and wall presets.
fieldsEvery spec field: its type, unit, range, default and one sentence on what it does. See section 8.
focusFieldsThe same for the keys of one focus entry, per list (stFoci, tFoci, qFoci).
stInjury, tInversionWhich leads each wall’s ST or T change moves. See section 8.
recipesReady specs for common tracings, each with what a check should report. See section 8.
guidanceHow the fields go together: constraints, compositions and disambiguations. See section 8.
signalThe signal export’s capability statement: content types, sample rates, leads, artefact modes, window modes and beat markers. Its presence is the capability probe for POST /v1/ecg/signal.
childhoodPaediatric ECG data. adultFromYears is the age above which the heart is drawn as an adult (16). ages[] is a table of rows, each with ageYears, rateBpm, rateBand, sinusArrhythmia and further keys.
avAtrial, cadence and ventricle tables, including escapeBands.
imprintMaxCharsMaximum characters for the imprint envelope field (currently 24).
attributionMaxCharsMaximum characters for the attribution envelope field (currently 64).
attributionPlacesLegal values for attributionPlace ("left", "right").
attributionLogoMaxBytesMaximum byte size of the SVG string in attributionLogo (currently 24 576).
attributionLogoHeightMm{min, max, default} for the logo height control. Its presence is the capability probe for the attributionLogoHeightMm envelope field.
attributionLogoOffsetMm{min, max, default} for the logo nudge, one range for both axes. Its presence is the capability probe for the attributionLogoOffsetXMm and attributionLogoOffsetYMm envelope fields.
renderVersionWhich drawing this engine produces. Changes when the same spec starts producing different bytes. See section 13.
headersThe response headers /render sends, and which are sent on every render rather than only when the spec earns one. The vocabulary describes an etag on the vocabulary response itself, but this API does not forward it: GET /v1/ecg/vocabulary here always answers 200 with the full body.
buildWhich deployment answered. Include it in a bug report.

Not exhaustive. The vocabulary carries further keys (e.g. pacPauses, vFoci, flutterCircuits, determinism, version). Ignore unrecognised keys.

Stable and cacheable. Fetch once at start-up, and again when renderVersion or build changes.

04 Rendering

POST /v1/ecg/render

Send one JSON object. The body mixes two kinds of field:

  • Spec fields describe the heart: rhythm, rateBpm, stFoci, patterns, transitions. They decide what the tracing shows.
  • Envelope fields describe the paper: the theme, the raster density, a credit line. They change how the image looks, not what the rhythm is.

The spec is parsed strictly. An unrecognised field returns 400 rather than being ignored.

Spec fields

rhythm is the only required field. Everything else defaults from it. {"rhythm":"af"} is a complete request.

FieldMeaning
rhythmA rhythm id from vocabulary.rhythms. Required.
rateBpmVentricular rate requested, in beats per minute (0–300). Defaults to the rhythm’s typical rate and is clamped to its band (vocabulary.rhythms[].rateBand). Under a counted cadence (fixed_ratio, wenckebach, dropped) the ventricular rate is the atrial rate divided by the ratio; this field is ignored. 0 reads as unstated. x-ecg-resolved.rateBpm reports what was drawn.
format"rhythm" (default) or "twelve_lead".
leadWhich lead a rhythm strip records. Defaults to "II".
leadLayoutHow a twelve-lead is arranged on the page: "standard" (international), "cabrera" (the Nordic order), "cabrera_split" or "standard_6x1" (several sheets). Read vocabulary.leadLayoutPages for the sheet count, and vocabulary.leadLayoutSpeedFactors for per-sheet speed factors. Twelve-lead only.
durationSecRecording length, 3 to 30 seconds. Defaults by format, layout and paper speed (vocabulary.durationDefaults): 10 s at 25 mm/s, 5 s at 50 mm/s.
paperSpeedMmPerSec25 or 50. Defaults by layout (vocabulary.paperSpeedDefaults): 25 for standard, 50 for Cabrera, 25 for cabrera_split and standard_6x1.
patientAgeYearsThe patient’s age, 0–120 years. Under 16 it draws a child’s ECG. See section 8, “Children”.
sinusArrhythmiaRespiratory sinus arrhythmia grade, 0–1, default 0. On sinus-driven rhythms the P–P interval varies with breathing. 0: physiological variability only. 0.5: plain sinus arrhythmia on a 10 s strip. 1: marked (~30%). Unset, adults draw 0; children draw the age’s grade from vocabulary.childhood.ages[].sinusArrhythmia. Inert on ectopic, paced, flutter or fibrillating atria.
stFociST changes, each centred on a wall or a lead: [{"epicenter":"inferior","mm":2}]. Positive raises, negative depresses. Entries add up. Preferred over stMm/stTerritory. Up to 12 entries (vocabulary.ranges.fociMax).
tFociThe same for the T wave: [{"epicenter":"anterior","mm":-3}] inverts it over the anterior wall.
qFociQ-wave changes, each centred on a wall or a lead.
stMm, stTerritoryST shift in millimetres and which wall. The older one-wall shorthand, still supported; prefer stFoci.
patternsA list of named findings from vocabulary.patterns, up to 4 (vocabulary.ranges.patternsMax). They combine.
conduction"none", "rbbb" or "lbbb".
prMs, qrsMs, qtcMsIntervals in milliseconds, as measured off the paper.
noiseArtefact: baseline wander, muscle hash, mains interference, movement, defibrillation.
seed, patientSeedReproducibility. See section 12.
transitionsA rhythm that changes partway through: an array of {"atSec":8,"rhythm":"vt"}, up to vocabulary.transitions.max changes (9). Unstated fields are inherited from the preceding section. atSec is a request: the engine moves each seam to the next beat. x-ecg-seams reports the times drawn.

Not the full list. vocabulary.fields carries every spec field with its type, unit, bounds, default and a description. Every field except rhythm is optional.

Envelope fields

Control how the tracing is drawn on the page. Do not change the rhythm or its measurements.

FieldDefaultNotes
theme"paper"One of vocabulary.themes. An unknown value returns 400.
finishnone (clean)"scan" or "photo": the sheet as scanned or photographed, with tone, light and marks. Cosmetic only (page size and lines stay put). Grain follows the recording’s seed. SVG carries tone and light but not grain. Limited to three paper themes (paper, red, mono); a 400 on any other. Read allowed themes from vocabulary.finishes[].themes. Capability-gated: check vocabulary.finishes first.
pixelsPerMm4Raster density, 1 to 12. Applies to PNG only; an SVG carries its own size in millimetres.
backgroundthe theme’s ownA CSS colour.
laneHeightMmby format16 to 60 mm of paper per lead (defaults: 32 rhythm strip, 40 twelve-lead). Non-numbers are refused; out-of-range values are clamped.
lanes11 to 6. Folds a rhythm strip into rows. A twelve-lead ignores it.
page1Which sheet, 1-based. Read x-ecg-pages or vocabulary.leadLayoutPages for the count. Out-of-range values are clamped; non-numbers are refused.
attributionnoneA credit line printed once in the footer, up to 64 characters (vocabulary.attributionMaxChars). See section 5.
attributionPlace"left"Where the credit sits: "left" (a footer row of its own) or "right" (shares the scale line’s band). One of vocabulary.attributionPlaces; an unknown value returns 400. See section 5.
attributionLogononeAn SVG logo as a string, up to 24 KB (vocabulary.attributionLogoMaxBytes). Drawn as a sandboxed image. See section 5.
attributionLogoHeightMmautomatic (11 mm)Logo box height, 6 to 35 mm. Capability-gated: check vocabulary.attributionLogoHeightMm before sending. See section 5.
attributionLogoOffsetXMm, attributionLogoOffsetYMm0Nudge the logo from where the layout puts it, −10 to 10 mm each; positive X is right, positive Y is up. Capability-gated: check vocabulary.attributionLogoOffsetMm before sending. See section 5.
imprintnoneUp to 24 characters (vocabulary.imprintMaxChars), printed once per lane in the recorder’s lettering. See section 5.

PNG or SVG

Default is PNG. For vector output, send an Accept header:

Accept: image/svg+xml

An SVG carries its own size in millimetres; pixelsPerMm does not apply. Accept: */* and a missing header both produce PNG.

Check that "image/svg+xml" appears in vocabulary.image.contentTypes before requesting it. An older deployment answers with a PNG and a 200, indistinguishable from success.

Response

The body is the image. Measurements are returned as response headers:

HeaderMeaning
x-ecg-formatrhythm or twelve_lead.
x-ecg-duration-secSeconds of recording on this sheet. On a multi-sheet layout’s detail page this may be less than the full recording.
x-ecg-paper-speed-mm-per-sec25 or 50, as drawn on this sheet. A detail page may run at a different speed from the overview (vocabulary.leadLayoutSpeedFactors).
x-ecg-pixels-per-mmDensity after clamping. PNG only. Use it to convert pixels to millimetres, then to millivolts and seconds.
x-ecg-page, x-ecg-pagesWhich sheet and how many. Sent on every render, including single-sheet formats (1 of 1).
x-ecg-rhythmThe rhythm id (the caller’s input echoed back). On a sequence, the first.
x-ecg-resolvedJSON object: rateBpm, prMs, qrsMs, axisDeg, qrsAmplitude and, where the sinus node drives the atria, sinusArrhythmia, as drawn, including unset fields. Also carries ignored, an array of fields overridden by something else in the spec (see Superseded fields below). On a sequence, describes the first segment.
x-ecg-seamsJSON array of seconds where the rhythm changed. Sent only with transitions. Times as drawn, which may differ from those requested.
x-ecg-advisoriesJSON array of plausibility notes, each starting “Check clinical plausibility”. Sent only when applicable. Observations, not refusals. Non-ASCII as JSON \uXXXX escapes.
x-ecg-plotJSON. Which seconds each rectangle of the sheet covers, in millimetres of paper. Use it to map time onto image coordinates.
x-ecg-render-versionWhich drawing produced this image.
x-ecg-key-labelThe name the key was issued under (non-ASCII replaced by ?). Sent on successful responses only.
x-squiggler-cachehit or miss. Whether the image was served from the server’s cache. Present on /render only; /check and /signal are not cached.

vocabulary.headers lists these headers as data, including which are sent on every render and which only when earned. The two conditional headers (x-ecg-seams and x-ecg-advisories) cannot be probed for: a render that earns neither is indistinguishable from an engine that never sends them.

Successful responses carry Cache-Control: private, max-age=86400 and Vary: Origin, x-api-key, Accept. Output is deterministic; client-side caching is safe. Marked private because the credential travels in a header.

Superseded fields

Some fields are accepted but not used when another field decides the same value. The render succeeds and the image is correct.

FieldDetermined byWhen
rateBpm atrialRateBpm ÷ conductionRatio A counted cadence: fixed_ratio, wenckebach, dropped.
rateBpm escapeRateBpm cadence: "dissociated". Nothing conducts; the ventricle follows its own focus.

vocabulary.guidance.constraints states both as entries with kind: "superseded". x-ecg-resolved.ignored lists the fields that were not used (absent when none). A clamped rate is not listed: the field did draw the tracing, bounded into the rhythm’s band. x-ecg-resolved.rateBpm reports where it landed.

See also:

05 Credit lines, logos and imprints

Three envelope fields put text or a mark on the paper, in different places.

attribution

A credit line printed once in the footer, in the recorder’s own lettering. Use it for a dataset licence line or a publisher’s mark.

Up to 64 characters (vocabulary.attributionMaxChars). Control characters are refused. Whitespace is collapsed to single spaces.

attributionPlace

Where the credit sits. Two positions, from vocabulary.attributionPlaces:

  • "left" (default): a footer row beneath the tracing.
  • "right": shares the scale line’s band, so the sheet does not grow taller.

An unrecognised value returns 400. The logo (below) sits in the same corner: on the left above the scale line, on the right above the credit line. The field applies to a logo even without a credit line.

attributionLogo

An SVG logo drawn beside or instead of the credit line, passed as a string. Rendered as a sandboxed image; scripts and external references cannot execute.

Stands in the credit’s corner above the scale line, 11 mm tall by default, growing up to 22 mm into the bottom lane’s empty half. A chosen size (attributionLogoHeightMm) goes up to 35 mm. Width is capped at 90 mm, rising to four times the chosen height (at most 140 mm) above 22.5 mm, and to half the sheet’s width on short strips (never below 60 mm).

Every one of these rules must be met, or the request returns 400 naming which rule it broke:

  • At most 24 KB of UTF-8 (vocabulary.attributionLogoMaxBytes).
  • The root element must be <svg>, and the document must end with </svg>.
  • Must have a viewBox, or a width and height in plain units, so it can be scaled.
  • No <script>, <foreignObject>, <iframe>, <object> or <embed>.
  • No filters (<filter>, feGaussianBlur and the like, or a filter property in a style).
  • No DOCTYPE or entity declarations.
  • No @import.
  • References (href, url()) may only point inside the file (#id) or carry an inline data:image. External references are refused.
  • No circular references.
  • Must not expand to more than 5,000 instantiated elements once internal references are resolved. A typical vector logo is a few hundred.

Beyond these checks, each new logo is drawn once in a separate process before use. A file that crashes the rasteriser is refused.

attributionLogoHeightMm

Fixes the logo’s box height, 6 to 35 mm (bounds in vocabulary.attributionLogoHeightMm). Out-of-range values are clamped. Non-numbers return 400. Defaults to 11 mm.

Capability-gated. Check that vocabulary.attributionLogoHeightMm is present before sending. The envelope is strict: an engine that predates this field refuses the entire request. The same rule applies to every post-launch envelope capability.

attributionLogoOffsetXMm and attributionLogoOffsetYMm

Nudge the logo from its default position. Positive attributionLogoOffsetXMm moves right; positive attributionLogoOffsetYMm moves up. −10 to 10 mm each (bounds in vocabulary.attributionLogoOffsetMm, one range for both axes). Out-of-range values are clamped, then the logo is held on the sheet (it can reach the edge but not cross it). Non-numbers return 400. The nudge moves the logo only; nothing else on the sheet changes. Omitted or 0, the output is byte-identical to a render without the field. No effect without a logo.

Capability-gated. Check that vocabulary.attributionLogoOffsetMm is present before sending either.

imprint

A short line printed once per lane in the recorder’s lettering. Repeats across every lane, so it survives cropping. Up to 24 characters (vocabulary.imprintMaxChars). Control characters are refused.

Capability-gated. Check that vocabulary.imprintMaxChars is present before sending.

06 Checking a spec

POST /v1/ecg/check

Takes the same body as /v1/ecg/render (spec and envelope fields together). Returns what the spec would draw as JSON instead of an image. Envelope fields are accepted and ignored, so the same object works for both routes.

curl -X POST "$ECG_API/v1/ecg/check" \
  -H "x-api-key: $ECG_KEY" \
  -H "content-type: application/json" \
  -d '{"rhythm":"sinus","format":"twelve_lead","stFoci":[{"epicenter":"inferior","mm":3}],"seed":"demo","patientSeed":"demo"}'

The answer, with five of the twelve leads shown:

{
  "ok": true,
  "label": "Sinus rhythm",
  "advisories": [],
  "measured": {
    "ventricularRateBpm": 78,
    "atrialRateBpm": 78,
    "moreAtrialThanVentricular": false,
    "beatKinds": ["narrow"],
    "beats": 13,
    "durationSec": 10,
    "drawn": {"rateBpm": 78, "prMs": 167, "qrsMs": 91, "axisDeg": 53, "qrsAmplitude": 1, "sinusArrhythmia": 0},
    "leads": {
      "I": {"stVsPr": -0.67, "stVsTp": -0.86, "prVsTp": -0.18},
      "II": {"stVsPr": 2.27, "stVsTp": 2.11, "prVsTp": -0.16},
      "III": {"stVsPr": 2.94, "stVsTp": 2.97, "prVsTp": 0.02},
      "aVL": {"stVsPr": -1.81, "stVsTp": -1.91, "prVsTp": -0.1},
      "aVF": {"stVsPr": 2.61, "stVsTp": 2.54, "prVsTp": -0.07},
      "…": {}
    }
  },
  "renderVersion": "56"
}

Response fields

FieldMeaning
oktrue on success.
labelThe rhythm name as drawn. On a sequence, names every section. Names the rhythm, not the findings (ST elevation appears in measured.leads, not here).
advisoriesThe same plausibility lines as the x-ecg-advisories render header. Empty when none apply. Warnings, not errors.
measured.ventricularRateBpmVentricular rate counted off the drawn beats, not copied from the spec.
measured.atrialRateBpmAtrial rate counted off the drawn P waves.
measured.moreAtrialThanVentriculartrue when some P waves are not conducted.
measured.beatKindsWhich kinds of complex appear: "narrow", "wide", "paced", "ectopic" and so on.
measured.beatsNumber of QRS complexes drawn.
measured.durationSecRecording length in seconds.
measured.drawnSame as the x-ecg-resolved render header: rateBpm, prMs, qrsMs, axisDeg, qrsAmplitude and, where applicable, sinusArrhythmia, as drawn, including unset fields. prMs is absent where the tracing has no PR interval.
measured.dissociationPresent only under cadence: "interference": the capture and fusion beats actually drawn, and faint fusions too slight to read as one. Compare with the captureBeats and fusionBeats requested.
measured.leadsPer lead, in millimetres: stVsPr is the ST level against the PR segment (the clinical reading), stVsTp against the TP baseline, prVsTp is PR depression. A rhythm strip reports one lead. Most useful on narrow rhythms (on a broad complex the ST window falls inside the QRS).
renderVersionWhich drawing the answer describes. Matches x-ecg-render-version on a render.

Response headers

A successful check carries x-ecg-render-version, x-ecg-key-label and Cache-Control: private, max-age=86400. No x-squiggler-cache; the server does not cache check results.

Errors

A 400 has the same shape as a render’s: error, issues[] with a hint per issue. The envelope is validated identically, including logo checks, so a body that passes the check passes the render.

Rate limit

Counts against the render lane (it synthesises the same recording).

Determinism

Deterministic: pin both seeds and the same spec gives the same answer within one render version.

See also:

07 Signal export

POST /v1/ecg/signal

Returns the tracing as samples: voltage per lead at a fixed rate, with each beat’s timing. For products that draw their own trace (a simulator, a physiology engine).

Takes the render body (envelope fields accepted and ignored) plus a signal object. Platform licence holders can also download it from the studio’s Export dialog (CSV or JSON, all twelve leads at 500 Hz).

OptionValuesDefault
leadsAny of the twelve leads, in the column order you want.All twelve
sampleRateHz500, the engine’s own rate, or 250 (low-pass filtered, then decimated).500
artifactrecording, with the artefact a render draws, or clean, with none.recording
windowstrip, the whole recording, or cycle, one cardiac cycle cut so it loops.strip
formatjson or csv. Accept: text/csv also asks for CSV; the body wins.json

Legal values for each option are at vocabulary.signal, whose presence is also the capability probe for the route.

curl -X POST "$ECG_API/v1/ecg/signal" \
  -H "x-api-key: $ECG_KEY" \
  -H "content-type: application/json" \
  -d '{"rhythm":"sinus","patterns":["hyperkalaemia"],"durationSec":10,"seed":"k1","patientSeed":"k1","signal":{"leads":["II","V5"],"sampleRateHz":500}}'

The answer, with sample arrays and beat list shortened:

{
  "sampleRateHz": 500,
  "units": "mV",
  "artifact": "recording",
  "window": "strip",
  "durationSec": 10,
  "startSec": 0,
  "leads": ["II", "V5"],
  "samples": { "II": [0.264, 0.278, 0.294, "…"], "V5": [0.483, 0.499, 0.538, "…"] },
  "beats": [
    { "kind": "narrow", "pOnset": 0.36, "qrsOnset": 0.528, "rPeak": 0.576, "qrsEnd": 0.638, "tEnd": 0.864 },
    "…"
  ],
  "pWaves": [ { "onset": 0.36, "peak": 0.398, "end": 0.442 }, "…" ],
  "label": "Sinus rhythm",
  "resolved": { "rateBpm": 78, "prMs": 214, "qrsMs": 84, "axisDeg": 53, "qrsAmplitude": 1, "sinusArrhythmia": 0 },
  "renderVersion": "56"
}

Response fields

FieldMeaning
samplesOne array per lead, all the same length, in millivolts to the microvolt (three decimals). Sample i is at startSec + i / sampleRateHz seconds into the recording.
durationSec, startSecHow much signal there is, and where it starts in the recording: 0 for a strip, the cut point for a cycle.
beatsEach ventricular complex, in seconds from the first sample: pOnset, qrsOnset, rPeak, qrsEnd, tEnd and kind (narrow, wide, ectopic, paced). pOnset is null without a driving P wave (AF, flutter, junctional, ventricular rhythms, AV dissociation). tEnd is null when the next complex arrives first.
pWavesEach P wave: onset, peak, end, and source when not sinus (ectopic, retro, paced). Under complete heart block this is the only record of the P waves.
labelThe rhythm’s name, as the check’s label gives it.
resolvedThe dials as drawn. Same object as the x-ecg-resolved render header.
renderVersionWhich drawing the numbers come from. Matches x-ecg-render-version.

Response headers

A successful signal export carries x-ecg-render-version, x-ecg-rhythm, x-ecg-resolved, x-ecg-sample-rate-hz, x-ecg-key-label, Cache-Control: private, max-age=86400 and Vary: Origin, x-api-key, Accept. x-ecg-advisories is present when there is something to say. A CSV response has content-type: text/csv; charset=utf-8. No x-squiggler-cache; signal exports are not cached server-side.

CSV

Columns: time_s, one per lead, and marker naming beat events on that sample (P_ON, QRS_ON, R, QRS_END, T_END, joined with | when two coincide). Empty elsewhere.

time_s,II,V5,marker
0.000,0.264,0.483,
0.002,0.278,0.499,
…
0.360,-0.013,-0.015,P_ON
…
0.528,0.004,-0.035,QRS_ON
…
0.576,0.982,1.665,R

Signal content

Twelve-lead basis. The export always draws the twelve-lead recording and delivers the leads requested, regardless of format and lead in the spec (a rhythm strip’s lead II and a twelve-lead’s differ slightly). Deterministic: same spec and seeds give the same numbers.

Beat timing. Measured on the clean recording regardless of the artifact setting, so it does not shift with noise. Measured across all twelve leads. Each wave is read from its own portion of the trace (P from atrial activity, QRS from depolarisation, T from repolarisation), so tEnd is never later than the onset of a P wave driving the next complex (where that P begins after the T’s peak). Cross-lead measurement reads the QRS slightly wider, and the PR slightly shorter, than the single-lead intervals in resolved. Where a U wave follows the T (e.g. hypokalaemia), tEnd covers both.

window: "cycle" cuts one cardiac cycle from the middle of the recording. Starts in the flat stretch before a P wave (or QRS without one) and ends exactly one beat later, so copies join seamlessly. Use clean artefact, or the noise will not loop. Rhythms without repeating beats (VF, asystole) return 400.

Errors and limits

Invalid options return 400 naming the problem. Spec errors have the render’s shape, with a hint per issue. Counts against the render lane.

See also:

08 Writing specs

A spec is a JSON object describing the heart. Only rhythm is required; every other field defaults from it. The schema is strict: an unrecognised key returns 400.

The vocabulary carries three keys describing the fields and their relationships. Read them from the vocabulary; the lists grow.

fields and focusFields

vocabulary.fields has one entry per spec field (68 at writing), grouped by domain (rhythm, recording, av, intervals, qrs, st, t, ectopy, pacing, findings, artifact, sequence, patient). Each entry carries:

KeyContents
nameThe spec field name.
groupWhich domain group the field belongs to.
typenumber, integer, enum, enum[] (a list of values; territory fields also accept a single bare value), list (of objects), object or string.
unitmm, ms, bpm, deg, per_min, sec, pct, ratio or years. Absent on dimensionless fields.
min, maxThe bounds the parser enforces. On a list or enum[], max is the most entries.
normalWhere the value usually sits, where the domain has a normal band.
defaultWhat an unset field draws. "patient" means drawn per patient (two seeds differ); an explicit value replaces the draw. Absent when the default depends on rhythm or layout.
values, valuesFromLegal values, or the vocabulary key that lists them (e.g. rhythms, patterns, leadLayouts).
needsIds from guidance.constraints: what else must be true for the field to draw anything.
preferSet on the older one-wall ST fields (stMm, stTerritory, etc.); they still work, but stFoci is preferred.
summaryOne sentence describing the field.
exampleA legal value.

vocabulary.focusFields carries the same information for the keys of one stFoci entry, one tFoci entry and one qFoci entry. The three lists share epicenter, mm, spread and frontalDeg; an ST focus also carries reciprocal, shape and tilt.

Bounds and values are read off the engine’s validator: they are the ones a 400 enforces.

guidance

vocabulary.guidance has three lists describing how the fields go together.

constraints (17 at writing): what a field needs to draw, and which fields are mutually exclusive or superseded. Each entry has id, fields, rule and why. Rule kinds:

KindMeaning
needs_formatMeaningless on any other format (typically "twelve_lead"). A territory on a single-lead strip renders but cannot be shown.
needs_fieldDescribes another field’s effect and draws nothing without it (e.g. stShape needs stMm or stFoci).
exclusiveTwo fields set the same value; use only one (e.g. qtMs and qtcMs: the absolute interval wins).
supersededAccepted but not used. Two cases: rateBpm under a counted cadence, and rateBpm under cadence: "dissociated". x-ecg-resolved.ignored reports overridden fields.
inertAccepted but inert in the stated context (e.g. spread on a frontal focus has no chest lead to reach).
clampedBounded more tightly per context (e.g. escapeRateBpm accepts 15 to 130 but each escape focus has a narrower band at av.escapeBands).

Most constraints are not enforced by the render route (a territory on a single-lead strip renders but cannot be shown). Apply them client-side. Do not hardcode the list; it grows.

compositions (9 at writing): common multi-field setups. Each has id, label, when and the spec fragment it produces. Use as starting points for arrangements like flutter with variable block or complete heart block with narrow escape.

disambiguations (8 at writing): resolves ambiguous requests. Each has id, ask, use, insteadOf and because. Consult before writing a spec that could read two ways.

ST and T changes

Two forms. stFoci (preferred): a list of up to 12 entries, each a change centred on a wall or lead with a signed size in millimetres. The one-wall shorthand (stMm, stTerritory, stSpread, stShape, stTilt, stReciprocal) still works for a single wall at a single size.

An stFoci entry has three kinds of epicenter:

KindepicenterLimb leads (I–aVF)Chest leads (V1–V6)
Wall"anterior", "inferior", "lateral", "posterior", "diffuse"YesYes
Chest lead"V1" … "V6"NoYes, as far as spread says
Frontal"frontal" with frontalDeg, or a limb lead ("I", "aVF", etc.)Yes, as one currentNo

mm is signed: positive is elevation facing the wall. A posterior wall at a positive mm draws as depression in V1–V3, since no lead on a standard sheet faces it. The reciprocal depression is drawn by itself.

Each ST focus may carry its own shape (−1 concave saddle to +1 convex dome), tilt (−1 resolving to +1 deepening) and reciprocal (0 to 1, how much of the reciprocal change to draw in the chest). Unset, a focus takes the recording-level stShape, stTilt and stReciprocal.

spread runs from 0 (the epicenter lead alone) to 1, with three named positions at vocabulary.foci.spread.anchors: focal at 0.25, regional at 0.5, widespread at 1. Acts on chest-lead and wall foci only; on a frontal or limb-lead focus it is inert (limb leads are geometry: III up is aVL down).

frontalDeg runs from −180 to 180, with 0 at the patient’s left (lead I) and +90 down (aVF). Required on a "frontal" epicenter; refused on every other. A limb-lead epicenter is shorthand for an angle: { "epicenter": "aVF", "mm": 3 } is the same current as { "epicenter": "frontal", "frontalDeg": 90, "mm": 3 } scaled by the augmented gain (0.866).

tFoci works the same way as stFoci, with mm negative for an inversion. Does not carry shape, tilt or reciprocal. The older one-wall form is tMm, tTerritory and tSpread.

vocabulary.stInjury lists each wall with the snippet to use, the spread value per reach word, the per-lead change per +1 mm, and which leads rise and fall. vocabulary.tInversion has the same shape.

Q waves

Set with qWaveMm and qWaveTerritory (the one-wall shorthand), or qFoci: a list of { epicenter, mm } entries like stFoci. mm is positive in the direction of the Q (a Q that deep); negative gives an early R. A posterior wall draws a tall R in V1 instead of a Q. Up to 12 entries (vocabulary.foci.max).

Children

Setting patientAgeYears below 16 (vocabulary.childhood.adultFromYears) draws a child’s ECG: right-ventricular dominance fading with age, a child’s voltages, q waves, axis, PR, QRS and QTc, the juvenile T in V1–V3, a child’s breathing and sinus arrhythmia, and a sinus rate defaulted and named for the age. Calibrated against Rijnbeek 2001 and Fleming 2011.

vocabulary.childhood.ages[] is a table of age rows. Each carries ageYears, rateBpm (the default sinus rate), rateBand (the normal rate range at that age) and sinusArrhythmia (the default grade, largest at school age). Explicit spec fields override the age’s defaults.

From 16, and when the field is unset, the heart is drawn as an adult’s. Over 40 the QRS gains a little terminal slurring.

Rhythm changes (transitions)

A recording that changes rhythm uses the transitions field: up to 9 entries (vocabulary.transitions.max), each with atSec (0.5 to 30 s) and a new rhythm. Unstated fields are inherited from the preceding section.

vocabulary.transitions.scoped lists which fields a transition entry may carry. These are the fields about the heart (rhythm, rate, intervals, axis, conduction, ST, T, Q, ectopy, pacing, findings and sinus arrhythmia), as distinct from recording fields (format, duration, paper speed), which apply to the whole strip.

atSec is a request: a rhythm changes between complexes, not inside one, so the engine moves each seam to the next beat. x-ecg-seams reports the times actually drawn. Read that header rather than echoing the request; the two may differ by up to one cardiac cycle.

Each entry also accepts respectRefractory. Unset, the change waits for the next beat. Set to false, it cuts in at the requested time (e.g. a shock, an R-on-T). Only the previous beat’s refractory period holds.

A shock is drawn with noise.defib: true and placed with noise.defibAtSec (see Artefact below), so it can land on a transition.

Pacing

A pacemaker is an arrangement on a rhythm, not only a rhythm. The three paced rhythm ids (paced, atrial_paced, av_paced) still work. A device can also be added to any rhythm the engine marks as paceable.

vocabulary.pacingModes lists three modes: atrial, sequential (dual-chamber) and ventricular. Each carries writes (the spec fields the mode sets) and rateField (escapeRateBpm for all three). To add a device, set the fields from the mode’s writes and set escapeRateBpm to the device’s programmed rate.

Which modes a rhythm supports: rhythms[].paceModes. Whether it can carry a device at all: rhythms[].paceable. Do not hardcode which rhythms are paceable; read the flag.

Pacemaker malfunctions, each 0 to 1:

FieldMeaning
exitBlockFailure to capture: share of pacing spikes the heart does not answer.
oversensingShare of paced beats withheld by a phantom signal: a pause with no spike.
undersensingShare of paced beats with an extra spike mid-cycle. Needs a conducting rhythm with a ventricular device and an escape rate (a backup pacemaker with native beats to miss).

rhythms[].conducts says whether a rhythm reaches the ventricle on its own, which undersensing needs.

Ectopy

Ventricular and atrial extrasystoles: pvcPerMin (0 to 100) and pacPerMin (0 to 100). Requires a rhythm that supplies beats to be early against (rhythms[].ectopics).

pvcCouplingMs (280 to 800 ms) fixes the coupling interval of every PVC: R of the preceding beat to R of the PVC, with ±25 ms per-beat scatter. Unset, each PVC lands at 44–62% of its cycle. Short values may land on the preceding T (this is how to draw R-on-T). Values later than ~85% of the cycle are held there (no longer premature) and an advisory reports it. Requires pvcPerMin.

Two fields refine atrial ectopics:

  • pacConductedPct (0 to 100): the share that conduct to the ventricle. At 0 every PAC is blocked (P wave with no QRS).
  • pacPause: what the sinus node does after a PAC. "reset" (less than compensatory, the default) or "compensatory". Legal values in vocabulary.pacPauses.

pEctopicDeg (−175 to 60 degrees) sets the ectopic atrial focus position, as a turn from the sinus P axis. A low focus draws inverted P waves in the inferior leads.

The ventricular focus for PVCs, escape beats, VT and paced exit is set with vFocus ("rv" or "lv", from vocabulary.vFoci), or more precisely with vFocusDeg (−110 to 110, the frontal angle) and vFocusRotDeg (−180 to 180, the transverse angle).

Artefact

The noise object controls recording artefact. Six magnitude dials, 0 to 2, each drawn per recording when unset. { "level": 0 } gives a clean trace.

KeyDefaultMeaning
level0.3Overall noise floor.
wander1Baseline wander.
muscle0.45Muscle tremor artefact.
mains0.3Mains interference.
motion0.25Movement artefact.
fidget0.7Small, constant fine movement.

mainsHz: 0 (none), 50 (default) or 60. leadContact (boolean, default false) simulates a loose electrode. defib (boolean, default false) draws a defibrillation artefact; defibAtSec places it at a specific time in seconds (can coincide with a transition). After a shock, the heart is silent for defibPauseSec (0 to 5 s; unset, drawn per recording between 0.5 and 3 s; 0 brings the rhythm straight back) before the rhythm resumes on a whole beat.

Full bounds of each noise dial: vocabulary.ranges.noise and vocabulary.bounds.

Named findings (patterns)

patterns: a list of up to 4 findings (vocabulary.ranges.patternsMax). They combine. Each is an id from vocabulary.patterns, carrying id, label, group, blurb, needsTwelveLead and leadScoped. 19 at writing, including Wellens A and B, De Winter, LVH, RVH, P pulmonale, P mitrale, S1Q3T3, hyperkalaemia, hypokalaemia, digoxin effect, Osborn waves, acute pericarditis, low voltage, electrical alternans, WPW, Brugada 1 and 2, and early repolarisation. vocabulary.patternGroups groups them for a menu.

Recipes

vocabulary.recipes: 25 ready-made specs (at writing), from normal sinus rhythm to inferoposterior STEMI to defibrillated VF. Each carries id, label, group, summary, spec (seeds pinned for reproducibility) and expect (what POST /v1/ecg/check reports: label, leads with ST ≥ +0.5 mm in up or ≤ −0.5 mm in down, and rateBpm where the rate is the point).

Copy a recipe, modify it, check, render.

09 Agents and the guide

For programs that write specs (language models, MCP tools, import scripts). The API reports what a spec will draw for callers that cannot look at the image.

  1. Read the guide once. GET /v1/ecg/guide returns a Markdown document: every field with unit and range, ST and T lead tables, recipes and rules. Generated from the same tables as /v1/ecg/vocabulary. Links are relative paths; resolve them against the base URL. Fetch once per session. Counts against the read lane, not the render lane.
  2. Write a spec. Start from a recipe in vocabulary.recipes when one is close. Each has id, label, the pinned spec, and expect (what a check reports for it).
  3. Check it. POST /v1/ecg/check. A 400 lists problems with a hint per issue. A 200 reports the rhythm name, rates, intervals and ST level per lead. Compare with intent and read any advisories. See section 6 for the full response.
  4. Fix and check again until the answer matches.
  5. Show it. POST /v1/ecg/render for an image, or POST /v1/ecg/link for a studio link when the program cannot pass an image.

Assistant setup

Two downloads provide the setup without writing a tool by hand. Both reference the guide, so they stay current as the vocabulary grows.

DownloadFor
Claude skill
Download the zip · read it
A skill that teaches Claude the check-then-show loop, the key handling and the rules worth knowing.
OpenAPI file
Download openapi.json
Every route as an OpenAPI 3.1 operation, with the key in the x-api-key header. For OpenAI’s custom GPTs and function tools, and any other tool that imports OpenAPI.

Claude.

  • Claude Code and the Agent SDK: unzip into .claude/skills/ in the project (or ~/.claude/skills/ for every project), and set SQUIGGLER_API_KEY in the environment.
  • Claude.ai: add the zip as a custom skill. The skill calls the API from Claude’s code sandbox, which needs network access to squiggler-api.fly.dev.

OpenAI.

  • A custom GPT: under Actions, import the schema from https://www.squiggler.io/apidocs/openapi.json, and set Authentication to an API key with a custom header named x-api-key. An action receives JSON, not pictures; instruct the GPT to check the spec and return the link from studioLink rather than calling renderEcg.
  • The Responses API or the Agents SDK: expose the operations as function tools and make the calls from your own server, where the key is.

Studio links

POST /v1/ecg/link

For callers that receive JSON (chat assistants, function tools) and cannot show a rendered PNG. Takes the render body, checks it, and returns a studio link with every dial live. The tracing travels in the fragment: nothing is stored.

curl -X POST "$ECG_API/v1/ecg/link" \
  -H "x-api-key: $ECG_KEY" \
  -H "content-type: application/json" \
  -d '{"rhythm":"sinus","format":"twelve_lead","stFoci":[{"epicenter":"inferior","mm":3}],"seed":"demo"}'
{
  "url": "https://www.squiggler.io/studio#s2=…",
  "label": "Sinus rhythm",
  "advisories": [],
  "renderVersion": "56"
}

label, advisories and renderVersion match the check response. The link carries the tracing and its theme; file-specific fields (pixelsPerMm, background, laneHeightMm, lanes, page, finish, credit, logo, imprint, signal) are excluded. A rejected spec returns the check’s 400. Counts against the render lane.

The guide

GET /v1/ecg/guide returns text/markdown: how to write a spec, every field grouped by domain, the ST and T tables, each recipe as JSON, constraint rules and the check-then-render loop.

curl -H "x-api-key: $ECG_KEY" "$ECG_API/v1/ecg/guide" -o ecg-guide.md

Successful responses carry x-ecg-render-version, x-ecg-key-label and Cache-Control: private, max-age=86400. An engine that predates the route answers 404, which this API turns into 501 rather than a retryable 502.

Checking from an agent

Pin both seeds and the check returns exactly what /render draws. A model cannot read an ECG image; it can read the numbers /check returns.

curl -X POST "$ECG_API/v1/ecg/check" \
  -H "x-api-key: $ECG_KEY" \
  -H "content-type: application/json" \
  -d '{"rhythm":"sinus","format":"twelve_lead","stFoci":[{"epicenter":"inferior","mm":2}]}'

Key handling

Pass the key through the environment, not the prompt. A key in a conversation is a key in a transcript.

10 Errors

Errors are JSON objects with an error string. A 400 adds issues[] (below). Pre-validation errors (authentication, rate limits, unreachable engine) add detail and retryable. Some engine errors carry a machine-readable code; use it for logging, but do not depend on a fixed set. The status code and error string suffice for all cases.

StatusMeaningRetry?Action
400The spec failed validation, or the body is not a JSON object, or an envelope value had the wrong type.NoFix the request. issues[] describes the problem.
401Key missing, unknown, revoked or expired. All four return the same response.NoCheck the credential.
402Pay-as-you-go keys only, on POST /v1/ecg/render. Either the key has reached its monthly spending cap (reason: "spend_cap_reached") or its subscription is not paid up (reason: "subscription_inactive"). retryable: false.NoRaise the cap in the account sheet, or update the card. Re-rendering a body already drawn this billing month still works at the cap. See Billing.
413Request body over 64 KB. Refused before it is read.NoReduce the body. A legal body is well under this limit (the logo’s own cap is 24 KB).
429Rate limited. Carries Retry-After and retryable: true.YesBack off. See section 11.
501Route not available on this deployment. The engine predates /check, /guide or /signal (/link runs a check, so it follows /check), or the deployment has no key store. retryable: false.NoWait for the deployment to catch up.
502ECG engine unreachable (may be waking from scale-to-zero). retryable: true.YesRetry in a moment.
503Two causes. (a) Renderer saturated or timed out: relayed from the engine with Retry-After and retryable: true. (b) Server not connected to an engine (misconfigured): retryable: false.See bodyRead retryable and Retry-After.

A relayed engine error with status 502 or above gains retryable: true. Engine refusals (400 with issues[], 503 with Retry-After) are relayed as the engine wrote them.

Validation errors (400)

A body that is not a JSON object returns:

{ "error": "The request body must be a rhythm spec object" }

A spec validation failure returns error, a fields array naming every key that needs fixing, and an issues array with one entry per problem. Each issue carries path, fields, message and often a hint.

A value out of range:

{
  "error": "Invalid ECG spec",
  "fields": ["rateBpm"],
  "issues": [
    { "path": "rateBpm", "message": "Number must be less than or equal to 300",
      "fields": ["rateBpm"], "hint": "rateBpm takes 0 to 300/min." }
  ]
}

A misspelled field. The schema is strict, so an unknown key is raised against the object and path is empty (this is why fields matters):

{
  "error": "Invalid ECG spec",
  "fields": ["stMM"],
  "issues": [{
    "path": "",
    "fields": ["stMM"],
    "message": "Unrecognized key(s) in object: 'stMM'",
    "hint": "\"stMM\" is not a field of the spec — did you mean \"stMm\"? See vocabulary.fields."
  }]
}

Most issues carry a hint: the nearest real field for a misspelling (at the top level, inside an stFoci or tFoci entry, or inside a transition), the legal values for a bad enum, the accepted range with its unit for a number out of bounds or of the wrong type. fields and hint are optional; path and message are always present.

Authentication errors (401)

{
  "error": "No valid API key",
  "detail": "Send your key in the x-api-key header. Keys are issued by Squiggler.",
  "retryable": false
}

Billing errors (402)

{
  "error": "This key has reached its monthly spending cap",
  "detail": "1167 unique ECGs drawn this billing period: 500 included, then €0.15 each, up to a cap of €100.00 above the base fee. Re-rendering a body already drawn this period is still free. The account holder can raise the cap from the account sheet at https://www.squiggler.io.",
  "reason": "spend_cap_reached",
  "uniqueEcgs": 1166,
  "limit": 1166,
  "capEuros": 100,
  "retryable": false
}

Rate-limit errors (429)

{
  "error": "Too many requests",
  "detail": "Slow down — up to N requests a minute. Try again in <time>.",
  "retryable": true
}

The Retry-After header says when to try again.

Engine unavailability (502, 503)

Engine unreachable:

{
  "error": "The ECG engine could not be reached",
  "detail": "It may be waking from scale-to-zero — try again in a moment.",
  "retryable": true
}

Server not connected to an engine:

{
  "error": "Not connected to an ECG engine",
  "detail": "The service is misconfigured; this is not something a caller can fix.",
  "retryable": false
}

Route not available (501)

Engine predates a route:

{
  "error": "POST /v1/ecg/check is not available on this deployment yet",
  "detail": "The rest of the API works. Try again later.",
  "retryable": false
}

No key store:

{
  "error": "This deployment does not serve the public API",
  "detail": "It has no database, so there is nowhere to verify a key.",
  "retryable": false
}

11 Rate limits

Limits apply per key, not per IP address. Machines sharing a key share its limit. Multiple keys get one limit each.

The six routes fall into two lanes:

LaneRoutesDefault limit
RenderPOST /v1/ecg/render, POST /v1/ecg/check, POST /v1/ecg/signal and POST /v1/ecg/link30 requests per minute, shared between the four
ReadGET /v1/ecg/vocabulary and GET /v1/ecg/guide30 requests per minute, shared between the two

The render lane’s four routes all synthesise the recording. Default: 30 per minute (one every two seconds). Per-key limits range from 1 to 6,000 requests per minute and can be raised without client changes.

Requests without a valid key are refused with 401 and limited to 30 per minute per IP address; beyond that they receive 429.

The two lanes are independent. Fetch vocabulary and guide once at start-up.

Over the limit: 429 with Retry-After and retryable: true.

Billing: pay as you go

A pay-as-you-go key is self-serve: subscribe from the pricing page while signed in, then create the key in the account sheet. It is shown once, never stored and never emailed; pressing Replace key revokes the old one at once. Self-serve keys get 120 requests per minute.

€149 a month, excluding VAT, includes 500 ECGs per billing month; each ECG after that is €0.15. Cancel any time from the account sheet (Stripe’s own page). The billing month is your subscription’s, not the calendar’s.

You pay per unique ECG, not per request. A unique ECG is a distinct request body to POST /v1/ecg/render — the spec, the seeds and the render options (pixelsPerMm, background, the lanes, the format chosen by Accept) — on one key within one billing month. Sending the same body again is free: the engine is deterministic, so it is the same tracing. Field order does not matter. A request the engine refuses (400, 5xx) is not billed.

Never billed: GET /v1/ecg/vocabulary, GET /v1/ecg/guide, POST /v1/ecg/check, POST /v1/ecg/link, and everything under /v1/sandbox/ecg. They are free, not unconditional: a key whose subscription is unpaid, or whose contract has ended, is refused on /check and /link with the same 402 as a render. POST /v1/ecg/signal is part of the Platform licence and is refused to a pay-as-you-go key with 403 and reason: "signal_not_included".

Spending cap. Every pay-as-you-go key has a monthly cap on what is billed above the base fee — €100 to start, set by you in the account sheet. At €0.15 an ECG, €100 is 666 ECGs past the included 500. A new body beyond the cap is refused with 402 and reason: "spend_cap_reached" (see Errors); bodies already drawn this month still render. Raising the cap takes effect on the next request.

Every render on a pay-as-you-go key carries two headers so your code can watch its own spend:

HeaderValue
x-squiggler-ecgnew (a unique ECG this month) or repeat (free)
x-squiggler-ecgs-this-periodUnique ECGs on this subscription so far this billing month

Founding price. €79 a month for the first six months for companies under a year old or not yet earning. Ask us for a promotion code and enter it at checkout; it ends on its own.

Platform contracts. A Platform customer has its own terms: a fee (monthly, every six months or yearly), a monthly allowance of unique ECGs shared by all of its keys, a price per ECG past the allowance, and a monthly spending cap. The allowance and the cap reset on the 1st of each month (UTC), whatever the fee’s interval. Unique ECGs are counted exactly as above, the same x-squiggler-ecg and x-squiggler-ecgs-this-period headers come back, and past the cap a new body is a 402 with reason: "spend_cap_reached" — Squiggler raises a contract’s cap. A key on a contract that has ended answers 402 with reason: "subscription_inactive" and status: "contract_ended".

12 Seeds and caching

Variation

Two requests for {"rhythm":"sinus"} with different seeds produce different images. The generator draws a patient (anatomy and wave shapes: frontal axis, QRS-T angle, voltage, Q depth, S depth, R height, T amplitude and timing, P morphology, ectopic/escape site) and then a recording of that patient (artefact placement, beat-to-beat timing, respiratory rate variation).

The two seeds

FieldControls
seedThis recording: artefact, beat timing and respiratory variation.
patientSeedWhose heart it is: anatomy and wave shapes.

Separate so that several tracings can show the same patient. Fix patientSeed, vary seed: a rhythm strip and a twelve-lead with the same patientSeed are the same heart.

What you changeResult
seed onlySame patient, new recording.
patientSeed onlyA different patient with the same rhythm.
Both fixedByte-identical output within one render version. See section 13.
Neither setA seed is derived from the spec. Still reproducible, but it changes whenever any spec field changes.

When seed is set and patientSeed omitted, the patient is drawn from seed. Seeds are arbitrary strings, up to 200 characters (vocabulary.ranges.seedMaxLength).

Morphology variation

morphologyVariation: 0 to 1, default 1. At 0, every patientSeed produces the same canonical heart. At 1, the full population spread. Scales proportionally.

Affects the patient only. Artefact and beat timing are controlled by noise and by the rhythm.

Explicit values

Explicit values replace drawn ones. Setting axisDeg overrides the patient’s axis; setting qrsMs overrides their QRS width. Variation only fills in unstated fields.

Caching

Responses carry Cache-Control: private, max-age=86400 and Vary: Origin, x-api-key, Accept. Output is deterministic per render version; client-side caching is safe. Marked private because the credential is in a header.

Render responses carry x-squiggler-cache: hit or miss. /check and /signal are not cached server-side.

When caching renders, include x-ecg-render-version in the cache key or clear the cache when it changes.

See also:

13 Versioning

The version is in the path. /v1 will continue to answer as /v1 does now.

The vocabulary grows (new rhythms, findings, fields). This is not a breaking change. Write clients to:

  • read enumerations from /v1/ecg/vocabulary rather than hardcoding them;
  • ignore vocabulary keys they do not recognise;
  • check for a capability before using it.

Removals and changes of meaning would arrive under a new path version.

Capability probes

Post-launch features appear in the vocabulary before they can be used. The envelope is strict: an engine that does not know a field refuses the whole render with a 400. Before sending a field, check that its vocabulary key is present.

Examples: check that "image/svg+xml" appears in vocabulary.image.contentTypes before requesting SVG. Check that vocabulary.attributionLogoHeightMm exists before sending attributionLogoHeightMm in the envelope. Check that vocabulary.transitions exists before sending transitions in the spec. Check that vocabulary.signal exists before calling POST /v1/ecg/signal. In each case, absence means the deployment predates the feature.

Render version

vocabulary.renderVersion and the x-ecg-render-version header identify which drawing the engine produces. Same spec and seeds give byte-identical output within one render version. When the engine changes what it draws (a corrected gain, a more faithful artefact model) the version moves.

Include the render version in cache keys or clear on change. Without it, an upgrade silently mixes old and new images.

14 Worked examples

A reproducible twelve-lead

Atrial flutter with 4:1 block and LBBB, on monitor paper, both seeds pinned:

curl -X POST "$ECG_API/v1/ecg/render" \
  -H "x-api-key: $ECG_KEY" \
  -H "content-type: application/json" \
  -d '{
        "rhythm": "aflutter",
        "conductionRatio": 4,
        "conduction": "lbbb",
        "format": "twelve_lead",
        "durationSec": 10,
        "theme": "monitor",
        "pixelsPerMm": 6,
        "patientSeed": "case-0042",
        "seed": "take-1"
      }' \
  -D headers.txt -o flutter.png

headers.txt reports the rhythm, resolved intervals and plot geometry. Repeating the request returns the same bytes within one render version. Changing seed to "take-2" gives another recording of the same patient.

A recording that changes rhythm

Sinus rhythm deteriorating into VF at 5 s, shocked back to sinus at 9 s:

curl -X POST "$ECG_API/v1/ecg/render" \
  -H "x-api-key: $ECG_KEY" \
  -H "content-type: application/json" \
  -d '{
        "rhythm": "sinus",
        "rateBpm": 82,
        "durationSec": 15,
        "transitions": [
          { "atSec": 5, "rhythm": "vf" },
          { "atSec": 9, "rhythm": "sinus", "rateBpm": 94, "respectRefractory": false }
        ],
        "noise": { "defib": true, "defibAtSec": 9 },
        "seed": "arrest-demo"
      }' \
  -o arrest.png

Transitions inherit unstated fields from the preceding section. atSec is a request; x-ecg-seams reports actual times. The shock (noise.defib and noise.defibAtSec) lands on the second change. respectRefractory: false lets it cut in immediately instead of waiting for the next VF cycle. noise.defibPauseSec controls the post-shock pause. The rhythm after the shock is set by the transition, not by the artefact.

A department-style sheet with a credit line and logo

Twelve-lead with right-side credit line, a logo sized against the vocabulary, and a per-lane imprint.

# Send attributionLogoHeightMm only if the vocabulary offers it.
curl -s -H "x-api-key: $ECG_KEY" "$ECG_API/v1/ecg/vocabulary" | jq '.attributionLogoHeightMm'
# {"min":6,"max":35,"default":11}   (null on a deployment without it)

curl -X POST "$ECG_API/v1/ecg/render" \
  -H "x-api-key: $ECG_KEY" \
  -H "content-type: application/json" \
  -d '{
        "rhythm": "sinus",
        "rateBpm": 72,
        "format": "twelve_lead",
        "pixelsPerMm": 8,
        "attribution": "City General Hospital",
        "attributionPlace": "right",
        "attributionLogo": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 40\"><rect width=\"80\" height=\"40\" rx=\"6\" fill=\"#246\"/><path d=\"M8 20h18l5-12 8 24 6-12h27\" fill=\"none\" stroke=\"#fff\" stroke-width=\"3\"/></svg>",
        "attributionLogoHeightMm": 14,
        "imprint": "ED bay 4",
        "seed": "dept-demo"
      }' \
  -o dept.png

See also: