# chartlink — the full manual (advanced path) chartlink — an agent-first platform for charts, maps and tables with live-updating embed links. NO API KEY YET? POST /api/signup (optional body {"name": "my project", "ref": "where you heard of chartlink"}) returns a workspace and its API key in one unauthenticated call — instantly usable. The key is shown EXACTLY ONCE: store it. There are no accounts, logins or sessions anywhere: the key is the workspace, an edit link is one chart, and a share URL is one published chart. Key rotation is POST /api/keys + DELETE /api/keys/{id}; credits are GET /api/billing-info and a checkout link from POST /api/checkout-link, which you hand to a human. WHAT AN ASSET IS CHART TYPES: line, bar, area, scatter, dumbbell, slope, heatmap, choropleth, symbol-map, table, pie, waterfall (GET /api/asset-types describes each; GET /api/asset-types/{type}/schema is the contract — read it before the first create.) An asset = type + data + config (+ brand). data holds typed columns and row-arrays; config holds everything about presentation (title and the other text elements, encoding, chart options). They are updated independently: PUT /assets/{id}/data replaces the numbers; PATCH /assets/{id} deep-merges config. Never resend what you are not changing. Tables (type "table") are assets too: embeds render sortable HTML, while image surfaces (.png, e.g. Substack) snapshot the first chart.maxRowsPng rows (default 15). Tables never truncate: every column claims exactly the width its content needs and every cell renders whole. A wide table simply renders wider, and every surface (PNG, embed, share page) shows the identical picture scaled to fit — so text length is YOUR editorial call: short labels and country codes keep tables crisp; look at the preview and judge. THE ITERATION LOOP 1. POST /assets — create a draft. The response includes urls.previewPng (renders the CURRENT draft, never cached; requires the same Authorization header as the API — it is NOT a public URL). A 200/201 means the config validated AND rendered. Public URLs (urls.png, urls.page, urls.embedUrl) serve only the published snapshot. 2. Look at the preview. PATCH /assets/{id} with a minimal configPatch to adjust. Deep-merge rules: objects merge recursively, arrays replace wholesale, explicit null clears a key. To REMOVE keys wholesale send "config" instead of configPatch — it replaces the entire config. 3. POST /assets/{id}/publish — snapshots an immutable version and flips every embed/PNG to it. Pass {"premium": true} to spend one credit at publish and pin the premium tier in the same call. (Shortcut: POST /assets with publish: true does steps 1 and 3 in one call, for a chart that is already final. Skip it while you are still looking at previews.) 4. Paste urls.embedIframe (iframe platforms) or urls.png + urls.page (Substack: insert the PNG as an image, link it to the page). urls.csv serves the published data publicly (data transparency — readers can cite your chart), and /a/{id}.json serves the published type + config + columns, so ANY agent can remix a chart it was shown: POST /assets with that type and config and its own data. URLs come from the response — never construct them yourself. DATA UPDATES (the core flow) PUT /assets/{id}/data?publish=auto replaces rows. If the asset is published, it republishes automatically — every embed, PNG, and the share page serve the new numbers immediately. publish=false stages the change on the draft instead; publish=true force-publishes a draft. The new data is checked against the chart's current config: a column the encoding names but the data lacks is a 400 naming the columns. When the columns CHANGE (a rename, a number column becoming a class column), send the new settings in the same call — {"data": …, "config": …} — so both are checked as a pair. The body wraps the payload in a "data" key — a bare {columns, rows} object is rejected: {"data": {"columns": [{"id": "year", "type": "date"}, {"id": "value", "type": "number"}], "rows": [["2024", 1.2], ["2025", 1.4]]}} Date columns parse "2024", "2024-03", "2024-03-15", "2024-Q2", full ISO timestamps and epoch numbers on their own. For anything else give the column a dateFormat (strftime tokens %Y %m %d %b %B %q %H %M %S): {"id": "day", "type": "date", "dateFormat": "%d/%m/%Y"} — it is tried first, so day/month order is never guessed. SELF-UPDATING CHARTS (data sources) PUT /assets/{id}/source {"url": "...", "refresh": "hourly"|"daily"|"weekly", "format": "auto"|"csv"|"json"} attaches a public URL the platform refetches on schedule: rows are replaced from it and a published asset republishes itself after every successful fetch — no agent in the loop, and never metered. The asset's columns stay authoritative: CSV headers / JSON object keys must match column ids or labels (JSON also accepts {"rows": [[...]]} positionally). Setting the source fetches ONCE immediately and returns the outcome — check fetch.ok and fix fetch.error before moving on. The last outcome is always visible on GET /assets/{id} as dataSourceState. POST /assets/{id}/source/fetch refreshes now; DELETE /assets/{id}/source detaches (data stays). This is also the BULK IMPORT path: for large datasets, don't paste thousands of rows through a tool call — put the CSV at a URL and attach it as the source (one fetch fills the asset; cap 2MB of data). Direct PUT /assets/{id}/data accepts the same 2MB over plain HTTP. DRAFT vs PUBLISHED Embeds always serve the last published snapshot. Draft edits are invisible to readers until publish. Pinned URLs (/a/{id}.png?v=3) are immutable forever. Unpublish → embeds return 410. Propagation after (re)publish: the share page and embed update on the next request; the bare .png/.svg URLs sit behind the CDN and can serve the previous render for up to ~2 minutes. Need the new render guaranteed? Use the pinned ?v=N URL from the publish response. CONCURRENCY Mutations take expectedVersion. On 409 you get {currentVersion, current}: re-merge your intent onto current and retry with the new expectedVersion. Do not blindly retry. PUT data may omit expectedVersion (wholesale refresh semantics). MAPS (choropleth geographies) A map colours REGIONS, and regions are data: chart.geography names a boundary set, and the region column names regions in it. Built in: "countries" (ISO alpha-2/alpha-3 or names), "us-states" (USPS codes; Alaska and Hawaii as insets) and "us-states-pr" (the same plus Puerto Rico as a third inset). Every country's states/provinces exist as "-regions" — de-regions, ru-regions, br-regions, in-regions… — with regions named by ISO 3166-2 code (DE-BY), the bare suffix (BY), the English or local name. Below the US states: "us-counties" (3,220 counties and Puerto Rico's municipios, 5-digit FIPS codes or "Name, ST"; same frame as us-states, so chart.borders {geography: "us-states-pr"} draws the state lines on top), "us-districts" (congressional districts of the 119th Congress, codes "CA-05", at-large "AK-AL"), "us-metros" (393 metropolitan statistical areas, 5-digit CBSA codes or the Census title), "us-zip3" (894 three-digit ZIP areas, codes "902") — all in the states' frame and insets, so chart.borders {geography: "us-states-pr"} lines up — and one lon/lat set of 5-digit ZIP codes per state: "us-zips-ca", "us-zips-tx", "us-zips-ny"… (ZCTA codes). GET /api/geographies lists every geography; GET /api/geographies/{id} lists its regions with codes, names and aliases — read it before naming regions. An unknown region fails loudly and names the closest matches; an unknown geography lists the valid ids. chart.bounds crops any lon/lat geography; a sub-national geography fits its own extent when bounds is omitted. Boundaries come from Natural Earth (public domain). chart.projection picks how the sphere is flattened. "auto" (default) is equal-area azimuthal for a regional crop and Natural Earth for the world. For a world data map prefer "equalEarth" or "naturalEarth" — they keep areas honest. "mercator" is what readers expect for a country or city crop (poles clamped at ±85°). "conicConformal" is the atlas standard for a mid-latitude continent or country (Europe, the US). "orthographic" is a globe facing the crop's centre — bounds pick the hemisphere it shows and may span at most 180°. "us-states" is pre-projected (Albers USA) and ignores projection. POINTS on maps, two ways. (1) The "symbol-map" type: rows ARE points — encoding {lon, lat, size?, color?, label?} puts a circle at each row's coordinates on a grey basemap, area ∝ the size column (radius ∝ √value), one palette colour per category of a STRING color column with a swatch legend, labels "none" | "all" | [names]. It takes the same chart.geography / bounds / projection as the choropleth. Points outside the crop are not drawn (all of them outside is an error); a size column gets a three-circle size legend (chart.sizeLegend). yet. (2) chart.pins on either map type: config-level pins [{lon, lat, label?, color?, size?}] for a capital, an event, a headquarters — a pin outside the drawn map fails loudly. Coordinates are lon THEN lat, the GeoJSON order; swapped columns fail with a range error. TEMPLATES (the fastest way to a chart that looks right) SEARCH FIRST: GET /api/templates?q= ("map of brazil's states", "ranked bars with flags", "change between two years") ranks templates by the need they answer — take the first. Each template has a page at /templates/{slug} with the need, the preview, the columns and the recipe. A template may carry variants: the same design in other looks, keyed "dark" (a dark canvas), "categories" (a map with a text category per region), "buckets" (values in classes) or a combination like "categories-dark" — hidden charts, not listed on their own; pass the variant's id as the template for that look. Any published chart is a template: POST /assets with {"template": "", "data": {...}, "config": {"title": {"text": "..."}}} copies its type and whole design and merges your config on top. Your data must supply the column ids the template's encoding expects — GET /api/templates/{id} lists them, and a mismatch is a 400 that names them. GET /api/templates lists charts their owners offered as starting points, each with urls.png: show a human three or four and ask which; or pick yourself. Humans browse the same list at /templates and hand you a chart id. Prefer a template over designing from a blank config: adapting a proven design beats inventing one. Offer your own chart as a template with PATCH {"showcase": true}. Remixing a chart you were merely shown works the same way — /a/{id}.json is its recipe. HAND-TUNING BY HUMANS (edit links) POST /assets/{id}/edit-link mints a no-login URL that opens a visual tweak panel for that ONE chart (text, colors, spacing, label nudges — same config you author, saved through the same API). PUBLISH FIRST: the editor saves and republishes a published chart; it cannot publish a draft. Offer it whenever the human's request is aesthetic fine-tuning; handing over the wheel beats a nudge-by-nudge loop. Scope if leaked: restyle/republish that one chart — never delete, never credits, nothing else. Revocable (DELETE /assets/{id}/edit-links/{tokenId}); edits stamp updatedBy "edit-link" so you can see when your human changed something. RENDERING SEMANTICS (the conventions you can't guess from schemas) Sizing: OMIT document.aspect and the canvas adapts to its content — the text elements take the space they need (a long description grows the canvas, never squeezes the plot), the plot keeps its natural size, row-driven types (dumbbell, heatmap, horizontal bars, table) grow with their rows, and choropleth derives height from its bounds. Set document.aspect only when the frame must be a fixed shape (a 1:1 social card, a 3:4 print slot). chart.bounds is a crop, and when document.aspect doesn't match its shape, more map shows on the letterboxed axis — omit aspect for the tightest crop. Layout is a document: the canvas takes document.box.padding, then the elements — title, description, legend, notes, source, chart — stack in that order, each inside its own box. There is no gap vocabulary: the space between two elements is the sum of the box.margins that touch. The chart takes whatever the text leaves, so chart.box.margin is measured from the document's padding box — 0 spans it exactly, and NEGATIVE values bleed outward past it toward the canvas edge. Full-bleed (edge-to-edge maps): set document.box.padding to 0 and give the text elements margins — {document: {box: {padding: {top: 0, right: 0, bottom: 0, left: 0}}}, title: {box: {margin: {left: 26, top: 26}}}, source: {box: {margin: {left: 26, bottom: 14}}}}. The chart then spans the canvas at its default 0 margin and the text keeps its inset. Prefer this over a negative chart margin: negative values are absolute, so they only reach the edge while the document padding happens to match them. Escaping the flow: give any element a position {space, x, y, anchor, verticalAnchor, offset} — x/y are fractions of the canvas (space "canvas", the default) or of the plot area (space "plot"), required together; anchor says which END of the block sits at x, verticalAnchor which edge sits at y; offset nudges in canvas units afterwards. The element is lifted out of the stack to that spot and the chart reclaims the room it would have taken. Free text works the same way: texts[] is an array of {text, font, align, box, position} drawn wherever you like, independent of title/description (set title.text and description.text to "" and caption purely with texts[]). The badge too: badge.position lifts it out of the footer row, so a full-bleed map with no source reaches the bottom edge and the badge sits over it in a corner, e.g. badge: {position: {x: 0.98, y: 0.97, anchor: "right", verticalAnchor: "bottom"}, font: {color: "#fff"}}. Without a position the badge shares the source line's row, and reserves it even when the source is off. Fonts inherit: a free text starts from the description's font, notes and the badge from the source's, an annotation from chart.annotationStyle.font; font{} overrides key by key. Anything positioned — an element or free text — renders ABOVE the chart, so a title can sit on top of a full-bleed map. Z-order: highlights and reference lines sit behind the data unless layer: "front"; annotations render above it; on bar charts the zero baseline draws over the bars so it never disappears; positioned elements draw above everything. Text over a busy fill: give the font an outline {width, color} and the glyphs get a stroke (paint-order stroke). This is EXPLICIT — the engine never adds a knockout halo behind map labels on a luminance guess. Map labels default to regionLabels.color "auto", which contrast-picks per region and needs no outline; a forced regionLabels.color with an outline on regionLabels.font is how you keep it readable. An 8-digit hex carries opacity (#161a22cc). A box behind anything: every element's box takes background, border {width, color, dash}, radius and padding — a title on a tinted band, a chart area with a hairline frame, an annotation as a callout box. Set background or border and the box is drawn; otherwise only its spacing applies. Labels: things that don't fit are DROPPED, never squeezed — heatmap cell values, choropleth region labels (small regions can get curated leader labels), scatter point labels (which also never cover a dot). seriesLabels.show "direct" names every series; suppress one by giving it label: "". Legends appear when direct labels can't (grouped bars, seriesLabels.show "legend"), and their swatches match the mark: circles for dots, strokes for lines, squares for fills. Color ramps: palette.diverging arrays read in ramp order — negative is extreme-first, positive is midpoint-first; the diverging midpoint is pinned to value 0. chart.domain (heatmap, choropleth) pins the scale; out-of-range values clamp to the ramp ends and the legend caps with ≤/≥. Hover (line, area): chart.tooltip "auto" lists every series at the crosshair when there are 8 or fewer, else only the line under the pointer plus emphasized series, brought forward; "all" / "nearest" / "none" force it. A series with label "" hides its direct label only — the tooltip names it anyway. Annotations are text elements in data space: {text, position: {x, y, offset}, font, align, box, leader, marker}. position.x/y is the point (a category, date or number on the x axis, a value on the left axis; space "canvas"/"plot" places by fraction instead); offset {x, y} is where the label sits relative to it. leader draws the line from the label to the point — true for the defaults, or {style: curve|straight|elbow, head: filled|open|none, from: left|right|top|bottom, curvature, stroke}; marker styles the dot. Several annotations that share a look set it ONCE in chart.annotationStyle {font, align, box, leader, marker} — merged under every entry, an entry's own keys win. An annotation with box.background is a callout box; there is no separate info-box type. Reference lines: {axis, value, layer, stroke {width, color, dash}, label {text, font, side}}; on a scatter axis "diagonal" draws y = x + value (value 0 = the identity line, the argument of any like-vs-like scatter). Highlights: {axis, from, to, layer, fill {color, alpha}, label {text, font}}. Bands (line): chart.bands [{lower, upper, series, color, alpha, label}] fill between two edges behind the lines — each edge a column id or a number: a confidence interval around a series (lower "p10", upper "p90", series "p50" takes the line's colour), the range between two series, or a reference zone between two numbers. matplotlib's fill_between. Scatter: chart.emphasize [names from the label column] colours those points with palette.emphasis and names them; the rest fade to palette.deemphasis — the six-countries-called-out treatment; chart.emphasisStyle {fill, stroke, radius, font} restyles them. encoding.size {column} sizes dots by area (chart.symbol {minRadius, maxRadius, opacity}) with a three-circle size legend (chart.sizeLegend). Date x axes: xAxis.ticks.count sets the tick density (14 for every 5 years over 1960–2025); gridlines follow the ticks. Maps: chart.context {geography, fill, stroke} draws a second geography beneath the map through the same projection and crop — a country's regions inside the world's countries, so a region map does not float; on a pre-projected US map it takes a same-frame geography (us-states-pr beneath us-metros: faint state lines under the metros, which stay whole where they straddle a state line). chart.borders {geography, width, color} draws a coarser geography's outlines ON TOP, unfilled — state borders over us-counties or us-zip3 (geography "us-states-pr"), country borders over a lon/lat region map. borders.only [codes or names] keeps just those regions of it — one state's outline over its ZIP codes ({geography: "us-states-outline", only: ["TX"]}; us-states-outline is the lon/lat outline of every state). chart.outline {width, color} draws the region outlines (default a 0.75 hairline in the document background; 0 removes them). preset: "map-light" | "map-dark" | "map-editorial" (+ "-surroundings") names the engine's own map design — padding, key spacing, hatching, edge, inset, colours, fonts — merged BENEATH the config at every render, so the design evolves with the engine; any key you set overrides it. The map templates carry one; a chart made from a template keeps it. chart.missing {style "plain"|"hatch", color} draws the regions with no data row as a flat half-strength grey or as thin diagonal hatching (the map templates hatch). chart.inset (canvas units) keeps the drawn geography that much inside the map area when nothing crops it — the water and surroundings still fill the area. chart.edge {width, color} draws a thicker line around the OUTSIDE of everything drawn — the state around its counties, the country around its municipalities — while the lines between regions stay hairline (default none; the map templates draw it at 1 in the line colour). chart.colorBar (maps and heatmaps) is the colour legend: style "gradient" (the bar; radius rounds its corners) or "dots" (a row of sample swatches, steps of them, each with its value underneath — 3 reads as low / middle / high); align left/center/right; position bottom (under the map), top (between the description and the map), or {x, y, anchor, verticalAnchor} to draw it OVER the map (the map then takes the whole plot — {x: 0.97, y: 0.95, anchor: "right", verticalAnchor: "bottom"} is the bottom-right corner); width "auto" | "full" | px; labelPosition below | sides. The top-level legend element is the SERIES KEY (class maps, lines, bars); a numeric map's legend is chart.colorBar. A map whose value column is TEXT is a class map: each region takes its class's colour from palette.categorical (first-appearance order — sort the rows to order the key) and the key is the top-level legend element: legend.direction "column" stacks one class per line; legend.position {space: "canvas", x: 0.97, y: 0.3, anchor: "right", verticalAnchor: "top"} puts it beside the map (give chart.box.margin.right room for it); legend.box {background, border, padding, radius} frames it. A projection or bounds on the pre-projected us-states is an error, and a regionColors key that names no region of the geography is an error with the near misses. Dumbbell: encoding.points [{column, label, color, labelPosition}] takes two OR MORE value columns per row (2000 · 2012 · 2024); the connector runs from the leftmost dot to the rightmost, the last point takes the palette's first colour, and value labels (valueLabels.show "all") sit beside the outer dots and above the inner ones unless a point's labelPosition says otherwise. Bars: sort "desc" orders by the first series; on a stacked bar add sortBy: "total" so the order is the visible bar length. stacked: "percent" (bars) and stack: "percent" (areas) normalise every bar or x to 100% so the segments read as shares; the axis becomes 0–100% and the hover shows share and value. colorBy "sign" (two colours around zero) or "value" (each bar shaded by its value along palette.sequential, or the diverging ramp when values span zero) — the ranked-and-shaded look without a categoryColors map by hand; it has no legend, so pair it with valueLabels: {show: "all"}. SETTINGS SHAPE A config is a document made of ELEMENTS. Every element has a few settings of its own and then supports shared MODULES — font, box, position, stroke — each defined ONCE (they are named $defs in the JSON Schema), the same wherever they appear. A module an element does not list is rejected there, never ignored. font {family, size, weight, lineHeight, color, outline{width, color}} box {margin{top,right,bottom,left}, padding{…}, background, border{width, color, dash}, radius} position {space: canvas|plot|data, x, y, anchor, verticalAnchor, offset{x, y}} stroke {width, color, dash} {document: {aspect, box{padding, background, border, radius}}, title: {text, font, align, box, position}, description: {...same...}, source: {text, url, ...same...}, notes: {...same...}, texts: [{text, font, align, box (no margin), position (required)}], legend: {font, box, position}, badge: {text, link, logoUrl, side, font, box, position}, chart: {box{margin, background, border, radius}, its ELEMENTS, and the type's own options} palette, colors, shape, encoding} A chart is elements too, and they differ by type — GET /api/asset-types/{type}/schema lists them: xAxis / yAxis {min, max, scale, ticks{count, format, show, labels{font}, marks{length, stroke}}, gridlines{show, stroke}, line{show, stroke} (bars/waterfalls: zeroLine)} seriesLabels (line, area) · valueLabels (bar, area, dumbbell, heatmap, waterfall) · pointLabels (scatter, symbol-map) · regionLabels (choropleth, + color) · sliceLabels (pie, + values, leaderAnchor) {show, font} rowLabels (dumbbell) · labels + headers (slope) · header + cells (table) {font} colorBar (heatmap, choropleth) {show, height, width, radius, labelPosition, labels{font}} outline (maps) {width, color} · centerLabel (pie) {text, font} · annotationStyle {font, align, box, leader, marker} There is no chart.fonts and no chrome: a tick font is xAxis.ticks.labels.font, a grid is yAxis.gridlines, the baseline is xAxis.line (yAxis.zeroLine on bars). title/description/source/notes and every entry of texts[] are the SAME element type. The named ones differ only in semantics — title becomes the aria label, the OG title and the listing title. An element with empty text is not drawn; there is no show/hide switch. text: breaks a line; [words](#hex) colours a span; text wraps at its box — the document column in the flow, the room to the canvas edge on the anchor's side when positioned. There is no wrap-width setting. One title with a is one element — do NOT build a two-line title out of two texts[] entries, and use description for the subtitle: those stay one thing to edit, move and hide. align (left|center|right) aligns the lines inside the block; a positioned element defaults to its anchor. RESTRAINT — one element per idea. A human will tune this chart in the editor afterwards, where every element you add is one more panel to find. A multi-colour headline is ONE title with [spans](#hex) and , not stacked or overlaid texts[]; a running count along a line is data labels or one annotation at the end, not one annotation per year; a grid is yAxis.gridlines, not a reference line per tick; a table is the table type, not texts[] laid out by hand. Position fractions are stored to 4 decimals — the canvas is 640 units wide, so 0.03 is as exact as 0.034375; write positions with 2–3 decimals and nudge from there. Discover it properly instead of guessing: GET /api/asset-types/{type}/schema serves the FULL JSON Schema, worked examples, AND "defaults" — the complete config in force when nothing is set (every font, margin and color), so you read a baseline value instead of guessing it. GET /assets/{id} returns the config in this same shape, so a read-edit-write loop stays in one vocabulary. There is exactly ONE shape. Older shapes (frame / theme / options; padding, place, enabled, branding, chart.text, infoBoxes, dx/dy, textStyle, arrow) are rejected, and the error names the key's new home. Errors always report the paths you wrote (title.font.size, chart.labels). than silently ignored. BRANDS A brand is default settings merged UNDER your config: any value the chart sets itself wins. The mechanical difference between the two layers: brand values PROPAGATE — edit the brand and every draft using it restyles, published charts pick it up on republish — while values written into the chart's own config are pinned to that one chart and never follow brand edits. Where you put a style decides how it ages. Pass brand: "" on create. Brand edits restyle drafts immediately; published assets pick them up on their next publish. Text styles accept ANY Google Fonts family by its exact name (fetched, measured, and cached server-side on first use); a family Google doesn't have fails loudly at brand save with a FontUnavailableError — Inter, "Source Serif 4", and "IBM Plex Mono" are always available. A brand is DEFAULT SETTINGS in the SAME shape as a chart's config, minus the two things a brand cannot own: the words (no text/url on the elements) and the type-specific chart options. So it reads exactly like the config above — {document: {box}, title: {font, align, box, position}, description/source/notes likewise, legend, badge, chart: {box + the style half of every element — xAxis.ticks.labels.font, yAxis.gridlines, seriesLabels.font, colorBar…}, palette, colors, shape}. Resolution is ONE merge across three layers: ENGINE_DEFAULTS <- brand <- the chart config. Example: more air above the title and a closer footer = {document: {box: {padding: {top: 28, bottom: 8}}}, source: {box: {margin: {top: 24}}}}. GET /api/brands/schema serves the generated JSON Schema — read it instead of guessing key names. GET /brands/{idOrSlug} returns the brand in the same shape. Unknown keys are rejected loudly at save time, and retired shapes are rejected with the key's new home. Two often-missed tokens: chart.xAxis.gridlines.show turns on vertical gridlines (line/area/scatter; off by default), and the diverging ramp flips by overriding it directly — e.g. blue=positive: {palette: {diverging: {negative: [reds...], positive: [blues...]}}} (swap the two ramps; heatmap, choropleth, and bar colorBy:"sign" all read them). ERRORS unauthorized (check Authorization header) · validation_error (issues[] carries paths, enum options, and a schema link — fix and retry) · not_found (wrong id or other workspace) · version_conflict (see CONCURRENCY). Errors are never punitive; when stuck, POST /api/feedback so the owner can fix the gap — do not silently give up. CREDITS (no subscriptions, no metering) HOSTING IS FREE: publish as many charts as you like — live-updating embeds, stable public URLs, full-resolution PNGs (up to 2400px), with the chartlink badge. Republishing and data updates are NEVER metered on any tier (updating is the product). Credits buy PREMIUM, per chart, once: SVG export, custom footer branding, no badge — that slot is premium forever. Packs: 1/$2.90, 10/$19, 50/$59. An asset's hostedTier is "trial" (free hosting: badge, PNG to 1200px, no expiry — "trial" is the tier's name, not a countdown) or "premium". GET /api/billing-info shows credits. To buy: POST /api/checkout-link {"pack":"1"|"10"|"50"} returns a payment URL to hand to your human — it opens straight into checkout (no login; the link can only add credits to this workspace, so sharing it is safe). Upgrade any hosted chart in place with POST /assets/{id}/premium (one credit, same URLs). CONVENTIONS List responses: {data, total, limit, offset}; list rows elide config/data (fetch one asset for full bodies). Timestamps are ISO-8601 UTC. Deletes are soft (POST /assets/{id}/restore). Data cap: 2 MB per asset — aggregate, don't paginate charts. ## Endpoints - https://chartlink.app/api — discovery envelope (unauthenticated) - https://chartlink.app/api/openapi.json — full OpenAPI 3.1 spec - https://chartlink.app/api/docs — human-readable reference - https://chartlink.app/mcp?full=1 — MCP endpoint with every tool (Streamable HTTP, bearer = API key)