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.pngA 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.svgTrying 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.pngThree routes, and what is different about them:
POST /v1/sandbox/ecg/render: PNG only, whateverAcceptsays. Drawn at no more than 4 pixels per millimetre (a lowerpixelsPerMmis kept). Every lane carries the imprint SQUIGGLER SANDBOX, and the corner carries the Squiggler mark and a "not for production use" line. Anyimprint,attribution*orfinishfield you send is ignored.GET /v1/sandbox/ecg/vocabularyandGET /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
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:
| Key | Contents |
|---|---|
rhythms | Each rhythm id, its display label, a plausible rate band, which cadences it supports, and whether it can carry a pacemaker (paceable, paceModes). |
patterns, patternGroups | Named 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, leadLayouts | Rhythm 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). |
leadLayoutPages | How many sheets each layout produces. Read this rather than assuming which layouts are paged. |
leadLayoutSpeedFactors | Per-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]. |
paperSpeeds | The legal values for paperSpeedMmPerSec: [25, 50]. |
paperSpeedDefaults | Default paper speed per format and layout. |
durationDefaults | Default recording length per format, layout and paper speed. Range is 3–30 s. |
territories, combinableTerritories | Where an ST shift, T change or old infarct sits, and which walls may be combined. |
conduction | Bundle branch block options. |
intervals | PR, QRS and QT bands with the clinical thresholds marked. |
ranges | The 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. |
bounds | The 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. |
themes | Paper styles: paper, red, orange, blue, mono, monitor, bedside. |
finishes | Scanned 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. |
image | The content types a render can answer with (contentTypes: ["image/png","image/svg+xml"]) and the pixelsPerMm band (1–12, default 4). |
laneHeightMm, lanes | The bounds and defaults of those two envelope fields. |
pacingModes | Each pacing mode, what it writes onto the spec (writes), and which field carries the device’s rate (rateField). |
transitions | Capability and limits for rhythm changes on one recording: max (the most changes, currently 9), scoped (which fields a section may carry) and defibAtSec. |
foci | Geometry for stFoci, tFoci and qFoci: epicenters, spread anchors, frontal-plane projection, chest-lead shares, and wall presets. |
fields | Every spec field: its type, unit, range, default and one sentence on what it does. See section 8. |
focusFields | The same for the keys of one focus entry, per list (stFoci, tFoci, qFoci). |
stInjury, tInversion | Which leads each wall’s ST or T change moves. See section 8. |
recipes | Ready specs for common tracings, each with what a check should report. See section 8. |
guidance | How the fields go together: constraints, compositions and disambiguations. See section 8. |
signal | The 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. |
childhood | Paediatric 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. |
av | Atrial, cadence and ventricle tables, including escapeBands. |
imprintMaxChars | Maximum characters for the imprint envelope field (currently 24). |
attributionMaxChars | Maximum characters for the attribution envelope field (currently 64). |
attributionPlaces | Legal values for attributionPlace ("left", "right"). |
attributionLogoMaxBytes | Maximum 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. |
renderVersion | Which drawing this engine produces. Changes when the same spec starts producing different bytes. See section 13. |
headers | The 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. |
build | Which 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
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.
| Field | Meaning |
|---|---|
rhythm | A rhythm id from vocabulary.rhythms. Required. |
rateBpm | Ventricular 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". |
lead | Which lead a rhythm strip records. Defaults to "II". |
leadLayout | How 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. |
durationSec | Recording 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. |
paperSpeedMmPerSec | 25 or 50. Defaults by layout (vocabulary.paperSpeedDefaults): 25 for standard, 50 for Cabrera, 25 for cabrera_split and standard_6x1. |
patientAgeYears | The patient’s age, 0–120 years. Under 16 it draws a child’s ECG. See section 8, “Children”. |
sinusArrhythmia | Respiratory 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. |
stFoci | ST 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). |
tFoci | The same for the T wave: [{"epicenter":"anterior","mm":-3}] inverts it over the anterior wall. |
qFoci | Q-wave changes, each centred on a wall or a lead. |
stMm, stTerritory | ST shift in millimetres and which wall. The older one-wall shorthand, still supported; prefer stFoci. |
patterns | A list of named findings from vocabulary.patterns, up to 4 (vocabulary.ranges.patternsMax). They combine. |
conduction | "none", "rbbb" or "lbbb". |
prMs, qrsMs, qtcMs | Intervals in milliseconds, as measured off the paper. |
noise | Artefact: baseline wander, muscle hash, mains interference, movement, defibrillation. |
seed, patientSeed | Reproducibility. See section 12. |
transitions | A 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.
| Field | Default | Notes |
|---|---|---|
theme | "paper" | One of vocabulary.themes. An unknown value returns 400. |
finish | none (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. |
pixelsPerMm | 4 | Raster density, 1 to 12. Applies to PNG only; an SVG carries its own size in millimetres. |
background | the theme’s own | A CSS colour. |
laneHeightMm | by format | 16 to 60 mm of paper per lead (defaults: 32 rhythm strip, 40 twelve-lead). Non-numbers are refused; out-of-range values are clamped. |
lanes | 1 | 1 to 6. Folds a rhythm strip into rows. A twelve-lead ignores it. |
page | 1 | Which sheet, 1-based. Read x-ecg-pages or vocabulary.leadLayoutPages for the count. Out-of-range values are clamped; non-numbers are refused. |
attribution | none | A 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. |
attributionLogo | none | An SVG logo as a string, up to 24 KB (vocabulary.attributionLogoMaxBytes). Drawn as a sandboxed image. See section 5. |
attributionLogoHeightMm | automatic (11 mm) | Logo box height, 6 to 35 mm. Capability-gated: check vocabulary.attributionLogoHeightMm before sending. See section 5. |
attributionLogoOffsetXMm, attributionLogoOffsetYMm | 0 | Nudge 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. |
imprint | none | Up 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:
| Header | Meaning |
|---|---|
x-ecg-format | rhythm or twelve_lead. |
x-ecg-duration-sec | Seconds 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-sec | 25 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-mm | Density after clamping. PNG only. Use it to convert pixels to millimetres, then to millivolts and seconds. |
x-ecg-page, x-ecg-pages | Which sheet and how many. Sent on every render, including single-sheet formats (1 of 1). |
x-ecg-rhythm | The rhythm id (the caller’s input echoed back). On a sequence, the first. |
x-ecg-resolved | JSON 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-seams | JSON array of seconds where the rhythm changed. Sent only with transitions. Times as drawn, which may differ from those requested. |
x-ecg-advisories | JSON array of plausibility notes, each starting “Check clinical plausibility”. Sent only when applicable. Observations, not refusals. Non-ASCII as JSON \uXXXX escapes. |
x-ecg-plot | JSON. Which seconds each rectangle of the sheet covers, in millimetres of paper. Use it to map time onto image coordinates. |
x-ecg-render-version | Which drawing produced this image. |
x-ecg-key-label | The name the key was issued under (non-ASCII replaced by ?). Sent on successful responses only. |
x-squiggler-cache | hit 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.
| Field | Determined by | When |
|---|---|---|
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:
- Writing specs for the full field reference (
vocabulary.fields). - Versioning for render version and caching.
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 awidthandheightin plain units, so it can be scaled. - No
<script>,<foreignObject>,<iframe>,<object>or<embed>. - No filters (
<filter>,feGaussianBlurand the like, or afilterproperty in a style). - No
DOCTYPEor entity declarations. - No
@import. - References (
href,url()) may only point inside the file (#id) or carry an inlinedata: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
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
| Field | Meaning |
|---|---|
ok | true on success. |
label | The rhythm name as drawn. On a sequence, names every section. Names the rhythm, not the findings (ST elevation appears in measured.leads, not here). |
advisories | The same plausibility lines as the x-ecg-advisories render header. Empty when none apply. Warnings, not errors. |
measured.ventricularRateBpm | Ventricular rate counted off the drawn beats, not copied from the spec. |
measured.atrialRateBpm | Atrial rate counted off the drawn P waves. |
measured.moreAtrialThanVentricular | true when some P waves are not conducted. |
measured.beatKinds | Which kinds of complex appear: "narrow", "wide", "paced", "ectopic" and so on. |
measured.beats | Number of QRS complexes drawn. |
measured.durationSec | Recording length in seconds. |
measured.drawn | Same 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.dissociation | Present 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.leads | Per 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). |
renderVersion | Which 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
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).
| Option | Values | Default |
|---|---|---|
leads | Any of the twelve leads, in the column order you want. | All twelve |
sampleRateHz | 500, the engine’s own rate, or 250 (low-pass filtered, then decimated). | 500 |
artifact | recording, with the artefact a render draws, or clean, with none. | recording |
window | strip, the whole recording, or cycle, one cardiac cycle cut so it loops. | strip |
format | json 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
| Field | Meaning |
|---|---|
samples | One 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, startSec | How much signal there is, and where it starts in the recording: 0 for a strip, the cut point for a cycle. |
beats | Each 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. |
pWaves | Each 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. |
label | The rhythm’s name, as the check’s label gives it. |
resolved | The dials as drawn. Same object as the x-ecg-resolved render header. |
renderVersion | Which 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,RSignal 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:
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.
- Read the guide once.
GET /v1/ecg/guidereturns 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. - Write a spec. Start from a recipe in
vocabulary.recipeswhen one is close. Each hasid,label, the pinnedspec, andexpect(what a check reports for it). - Check it.
POST /v1/ecg/check. A400lists problems with ahintper issue. A200reports the rhythm name, rates, intervals and ST level per lead. Compare with intent and read anyadvisories. See section 6 for the full response. - Fix and check again until the answer matches.
- Show it.
POST /v1/ecg/renderfor an image, orPOST /v1/ecg/linkfor 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.
| Download | For |
|---|---|
| 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 setSQUIGGLER_API_KEYin 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 namedx-api-key. An action receives JSON, not pictures; instruct the GPT to check the spec and return the link fromstudioLinkrather than callingrenderEcg. - 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
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.
| Status | Meaning | Retry? | Action |
|---|---|---|---|
| 400 | The spec failed validation, or the body is not a JSON object, or an envelope value had the wrong type. | No | Fix the request. issues[] describes the problem. |
| 401 | Key missing, unknown, revoked or expired. All four return the same response. | No | Check the credential. |
| 402 | Pay-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. | No | Raise 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. |
| 413 | Request body over 64 KB. Refused before it is read. | No | Reduce the body. A legal body is well under this limit (the logo’s own cap is 24 KB). |
| 429 | Rate limited. Carries Retry-After and retryable: true. | Yes | Back off. See section 11. |
| 501 | Route 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. | No | Wait for the deployment to catch up. |
| 502 | ECG engine unreachable (may be waking from scale-to-zero). retryable: true. | Yes | Retry in a moment. |
| 503 | Two 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 body | Read 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:
| Lane | Routes | Default limit |
|---|---|---|
| Render | POST /v1/ecg/render, POST /v1/ecg/check, POST /v1/ecg/signal and POST /v1/ecg/link | 30 requests per minute, shared between the four |
| Read | GET /v1/ecg/vocabulary and GET /v1/ecg/guide | 30 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:
| Header | Value |
|---|---|
x-squiggler-ecg | new (a unique ECG this month) or repeat (free) |
x-squiggler-ecgs-this-period | Unique 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
| Field | Controls |
|---|---|
seed | This recording: artefact, beat timing and respiratory variation. |
patientSeed | Whose 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 change | Result |
|---|---|
seed only | Same patient, new recording. |
patientSeed only | A different patient with the same rhythm. |
| Both fixed | Byte-identical output within one render version. See section 13. |
| Neither set | A 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/vocabularyrather 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.pngSee also: