{"openapi":"3.1.0","info":{"title":"chartlink API","version":"0.1.0","description":"chartlink — an agent-first platform for charts and tables with live-updating embed links.\n\nNO API KEY YET?\nPOST /api/signup (optional body {\"name\": \"my project\", \"ref\": \"where you heard of chartlink\"}) returns a\nworkspace and its API key in one unauthenticated call — instantly usable. The key is shown EXACTLY ONCE: store it. There are no\naccounts, logins or sessions anywhere: the key is the workspace, an edit link is one chart, and a\nshare URL is one published chart. Key rotation is POST /api/keys + DELETE /api/keys/{id}; credits\nare GET /api/billing-info and a checkout link from POST /api/checkout-link, which you hand to a human.\n\nWHAT AN ASSET IS\nAn asset = type + data + config (+ brand). data holds typed columns and row-arrays; config holds\neverything about presentation (title and the other text elements, encoding, chart options). They are updated independently:\nPUT /assets/{id}/data replaces the numbers; PATCH /assets/{id} deep-merges config. Never resend\nwhat you are not changing. Tables (type \"table\") are assets too: embeds render sortable HTML, while\nimage surfaces (.png, e.g. Substack) snapshot the first chart.maxRowsPng rows (default 15).\nTables never truncate: every column claims exactly the width its content needs and every cell\nrenders whole. A wide table simply renders wider, and every surface (PNG, embed, share page) shows\nthe identical picture scaled to fit — so text length is YOUR editorial call: short labels and\ncountry codes keep tables crisp; look at the preview and judge.\n\nTHE ITERATION LOOP\n1. POST /assets — create a draft. The response includes urls.previewPng (renders the CURRENT draft,\n   never cached; requires the same Authorization header as the API — it is NOT a public URL).\n   A 200/201 means the config validated AND rendered. Public URLs (urls.png, urls.page,\n   urls.embedUrl) serve only the published snapshot.\n2. Look at the preview. PATCH /assets/{id} with a minimal configPatch to adjust. Deep-merge rules:\n   objects merge recursively, arrays replace wholesale, explicit null clears a key. To REMOVE keys\n   wholesale send \"config\" instead of configPatch — it replaces the entire config.\n3. POST /assets/{id}/publish — snapshots an immutable version and flips every embed/PNG to it.\n   Pass {\"premium\": true} to spend one credit at publish and pin the premium tier in the same call.\n   (Shortcut: POST /assets with publish: true does steps 1 and 3 in one call, for a chart that is\n   already final. Skip it while you are still looking at previews.)\n4. Paste urls.embedIframe (iframe platforms) or urls.png + urls.page (Substack: insert the PNG as an\n   image, link it to the page). urls.csv serves the published data publicly (data transparency —\n   readers can cite your chart), and /a/{id}.json serves the published type + config + columns, so\n   ANY agent can remix a chart it was shown: POST /assets with that type and config and its own data.\n   URLs come from the response — never construct them yourself.\n\nDATA UPDATES (the core flow)\nPUT /assets/{id}/data?publish=auto replaces rows. If the asset is published, it republishes\nautomatically — every embed, PNG, and the share page serve the new numbers immediately.\npublish=false stages the change on the draft instead; publish=true force-publishes a draft.\nThe body wraps the payload in a \"data\" key — a bare {columns, rows} object is rejected:\n  {\"data\": {\"columns\": [{\"id\": \"year\", \"type\": \"date\"}, {\"id\": \"value\", \"type\": \"number\"}],\n            \"rows\": [[\"2024\", 1.2], [\"2025\", 1.4]]}}\nDate columns parse \"2024\", \"2024-03\", \"2024-03-15\", \"2024-Q2\", full ISO timestamps and epoch numbers\non their own. For anything else give the column a dateFormat (strftime tokens %Y %m %d %b %B %q %H %M %S):\n{\"id\": \"day\", \"type\": \"date\", \"dateFormat\": \"%d/%m/%Y\"} — it is tried first, so day/month order is never guessed.\n\nSELF-UPDATING CHARTS (data sources)\nPUT /assets/{id}/source {\"url\": \"...\", \"refresh\": \"hourly\"|\"daily\"|\"weekly\", \"format\": \"auto\"|\"csv\"|\"json\"}\nattaches a public URL the platform refetches on schedule: rows are replaced from it and a published\nasset republishes itself after every successful fetch — no agent in the loop, and never metered.\nThe asset's columns stay authoritative: CSV headers / JSON object keys must match column ids or\nlabels (JSON also accepts {\"rows\": [[...]]} positionally). Setting the source fetches ONCE\nimmediately and returns the outcome — check fetch.ok and fix fetch.error before moving on. The\nlast outcome is always visible on GET /assets/{id} as dataSourceState. POST /assets/{id}/source/fetch\nrefreshes now; DELETE /assets/{id}/source detaches (data stays).\nThis is also the BULK IMPORT path: for large datasets, don't paste thousands of rows through a tool\ncall — put the CSV at a URL and attach it as the source (one fetch fills the asset; cap 2MB of\ndata). Direct PUT /assets/{id}/data accepts the same 2MB over plain HTTP.\n\nDRAFT vs PUBLISHED\nEmbeds always serve the last published snapshot. Draft edits are invisible to readers until publish.\nPinned URLs (/a/{id}.png?v=3) are immutable forever. Unpublish → embeds return 410.\nPropagation after (re)publish: the share page and embed update on the next request; the bare\n.png/.svg URLs sit behind the CDN and can serve the previous render for up to ~2 minutes. Need\nthe new render guaranteed? Use the pinned ?v=N URL from the publish response.\n\nCONCURRENCY\nMutations take expectedVersion. On 409 you get {currentVersion, current}: re-merge your intent onto\ncurrent and retry with the new expectedVersion. Do not blindly retry. PUT data may omit\nexpectedVersion (wholesale refresh semantics).\n\nTEMPLATES (the fastest way to a chart that looks right)\nAny published chart is a template: POST /assets with {\"template\": \"<asset id>\", \"data\": {...}, \"config\":\n{\"title\": {\"text\": \"...\"}}} copies its type and whole design and merges your config on top. Your data\nmust supply the column ids the template's encoding expects — GET /api/templates/{id} lists them, and a\nmismatch is a 400 that names them. GET /api/templates lists charts their owners offered as starting\npoints, each with urls.png: show a human three or four and ask which; or pick yourself. Humans browse\nthe same list at /gallery and hand you a chart id. Prefer a template over designing from a blank\nconfig: adapting a proven design beats inventing one. Offer your own chart as a template with PATCH\n{\"showcase\": true}. Remixing a chart you were merely shown works the same way — /a/{id}.json is its\nrecipe.\n\nHAND-TUNING BY HUMANS (edit links)\nPOST /assets/{id}/edit-link mints a no-login URL that opens a visual tweak panel for that ONE\nchart (text, colors, spacing, label nudges — same config you author, saved through the same API).\nOffer it whenever the human's request is aesthetic fine-tuning; handing over the wheel beats a\nnudge-by-nudge loop. Scope if leaked: restyle/republish that one chart — never delete, never\ncredits, nothing else. Revocable (DELETE /assets/{id}/edit-links/{tokenId}); edits stamp\nupdatedBy \"edit-link\" so you can see when your human changed something.\n\nRENDERING SEMANTICS (the conventions you can't guess from schemas)\nSizing: OMIT document.aspect and the canvas adapts to its content — the text elements take the\nspace they need (a long description grows the canvas, never squeezes the plot), the plot keeps its\nnatural size, row-driven types (dumbbell, heatmap, horizontal bars, table) grow with their rows, and\nchoropleth derives height from its bounds. Set document.aspect only when the frame must be a fixed\nshape (a 1:1 social card, a 3:4 print slot). chart.bounds is a crop,\nand when document.aspect doesn't match its shape, more map shows on the letterboxed axis — omit\naspect for the tightest crop.\nLayout is a document: the canvas takes document.padding, then the elements — title, description,\nlegend, notes, source, chart — stack in that order, each inside its own padding box\n(<element>.padding {top,right,bottom,left}). There is no gap vocabulary: the space between two\nelements is simply the sum of the paddings that touch. The chart takes whatever the text leaves, so\nchart.padding is measured from the document's padding box — 0 spans it exactly, and NEGATIVE values\nbleed outward past it toward the canvas edge.\nFull-bleed (edge-to-edge maps): set document.padding to 0 and pad the text elements instead —\n{document: {padding: {top: 0, right: 0, bottom: 0, left: 0}},\n title: {padding: {left: 26, top: 26}}, source: {padding: {left: 26, bottom: 14}}}. The chart then\nspans the canvas at its default 0 padding and the text keeps its inset. Prefer this over negative\nchart padding: negative values are absolute, so they only reach the edge while the document padding\nhappens to match them.\nEscaping the flow: give any element a place {x, y, anchor, verticalAnchor} — x/y are fractions of\nthe canvas, required together; anchor says which END of the text sits at x, which is a different\nquestion from how the text aligns in its own box. The element is lifted out of the stack to that\nabsolute spot and the chart reclaims the room it would have taken. Free text works the same way:\ntexts[] is an array of {text, place, maxWidth, like, font} drawn wherever you like, independent of\ntitle/description (set title.text and description.text to \"\" and caption purely with texts[]).\nA free text starts from the description's font; like: \"title\" | \"source\" starts from that element's\ninstead, and font{} overrides key by key — so several captions share one look by sharing a like,\nnot by repeating a font. enabled: false hides one while keeping its words and its slot.\nAnything placed — a placed element or free text — renders ABOVE the chart, so a title can sit on\ntop of a full-bleed map.\nZ-order: highlights sit behind the data; reference lines, annotations, and info boxes render above\nit; on bar charts the zero baseline draws over the bars so it never disappears; placed text and\nfree text draw above everything.\nText over a busy fill: give the text style a borderWidth + borderColor and the glyphs get an outline\n(paint-order stroke). This is EXPLICIT — the engine no longer adds a knockout halo behind map labels\non a luminance guess. Map labels default to labelColor \"auto\", which contrast-picks per region and\nneeds no outline; force a single labelColor and an outline on chart.text.dataLabel is how you keep it\nreadable. An 8-digit hex carries opacity (#161a22cc).\nLabels: things that don't fit are DROPPED, never squeezed — heatmap cell values, choropleth region\nlabels (small regions can get curated leader labels), scatter point labels (which also never cover\na dot). labels: \"direct\" names every series; suppress one by giving it label: \"\". Legends appear\nwhen direct labels can't (dual axis, grouped bars), and their swatches match the mark: circles for\ndots, strokes for lines, squares for fills.\nColor ramps: palette.diverging arrays read in ramp order — negative is extreme-first, positive is\nmidpoint-first; the diverging midpoint is pinned to value 0. chart.domain (heatmap, choropleth)\npins the scale; out-of-range values clamp to the ramp ends and the legend caps with ≤/≥.\nAnnotation leaders (arrow) point from the placed text to the marker's edge; style: elbow gives\nright-angle editorial leaders, head: \"open\" the lighter chevron.\n\nSETTINGS SHAPE\nA config is a document made of ELEMENTS — each element is ONE node holding its content, font and\nspacing together, so the thing you point at is the thing you edit:\n  {document: {background, padding{top,right,bottom,left}, aspect},\n   title:       {text, font{family,size,weight,lineHeight,color,borderWidth,borderColor},\n                 padding{...}, place?},\n   description: {...same...}, source: {text, url, ...}, notes: {text, ...},\n   texts: [{text, enabled, like, place:{x,y,anchor,verticalAnchor}, maxWidth, font}],\n   legend: {font, padding, place}, branding: {text, link, logoUrl, position, font},\n   chart:  {padding, text:{tick,axisLabel,dataLabel,directLabel,annotation}, ...and every\n            type-specific option — orientation, yAxis, labels, annotations, bounds, ...},\n   palette, colors, shape, chrome, encoding}\ntitle/description/source/notes and every entry of texts[] are the SAME element type: text + font +\npadding + optional place. The named ones differ only in semantics — title becomes the aria label,\nthe OG title and the listing title. Hide any of them with text: \"\" (no title) or enabled: false\n(hidden for now — the words are kept, so it is reversible).\nText wraps to its width on its own; put a newline (\\n) in the string to force a break where you\nwant it. One title with a \\n is one element — do NOT build a two-line title out of two texts[]\nentries, and use description for the subtitle: those stay one thing to edit, move and hide.\nDiscover it properly instead of guessing: GET /api/asset-types/{type}/schema serves the FULL JSON\nSchema, worked examples, AND \"defaults\" — the complete config in force when nothing is set (every\nfont, padding and color), so you read a baseline value instead of guessing it. GET /assets/{id}\nreturns the config in this same shape, so a read-edit-write loop stays in one vocabulary.\nThere is exactly ONE shape. The older kind-grouped config (frame / theme / options) is retired: it is\nrejected, and the error carries the mapping (frame.title -> title.text, options.<key> -> chart.<key>,\ntheme.textStyles.<role> -> <element>.font). Errors always report the paths you wrote (title.font.size,\nchart.labels). place{} uses \"anchor\"/\"verticalAnchor\" (which end of the text sits at x/y) — distinct\nfrom a text element's own alignment.\nNOT implemented yet: \"sections\" (grouping title+description to place as one block) is rejected rather\nthan silently ignored.\n\nBRANDS\nA brand is default settings merged UNDER your config: any value the chart sets itself wins.\nThe mechanical difference between the two layers: brand values PROPAGATE — edit the brand and every\ndraft using it restyles, published charts pick it up on republish — while values written into the\nchart's own config are pinned to that one chart and never follow brand edits. Where you put a style\ndecides how it ages.\nPass brand: \"<slug>\" on create. Brand edits restyle drafts immediately; published assets pick them\nup on their next publish. Text styles accept ANY Google Fonts family by its exact name (fetched,\nmeasured, and cached server-side on first use); a family Google doesn't have fails loudly at brand\nsave with a FontUnavailableError — Inter, \"Source Serif 4\", and \"IBM Plex Mono\" are always available.\nA brand is DEFAULT SETTINGS in the SAME shape as a chart's config, minus the two things a brand\ncannot own: the words (no text/url on the elements) and the type-specific chart options. So it reads\nexactly like the config above — {document: {background, padding}, title: {font, padding, place},\ndescription/source/notes likewise, legend, branding, chart: {padding, text}, palette, colors, shape,\nchrome}. Resolution is ONE merge across three layers:  ENGINE_DEFAULTS <- brand <- the chart config.\nExample: more air above the title and a closer footer = {document: {padding: {top: 28, bottom: 8}},\nsource: {padding: {top: 24}}}.\nGET /api/brands/schema serves the generated JSON Schema — read it instead of guessing key names.\nGET /brands/{idOrSlug} returns the brand in the same shape. Unknown keys are rejected loudly at save\ntime, and the retired token shape (layout / textStyles) is rejected with its mapping.\nTwo often-missed tokens: chrome.grid.showX turns on vertical gridlines (line/area/scatter; off by\ndefault), and the diverging ramp flips by overriding it directly — e.g. blue=positive:\n{palette: {diverging: {negative: [reds...], positive: [blues...]}}} (swap the two ramps;\nheatmap, choropleth, and bar colorBy:\"sign\" all read them).\n\nERRORS\nunauthorized (check Authorization header) · validation_error (issues[] carries paths, enum options,\nand a schema link — fix and retry) · not_found (wrong id or other workspace) · version_conflict\n(see CONCURRENCY). Errors are never punitive; when stuck, POST /api/feedback so the owner can fix\nthe gap — do not silently give up.\n\nCREDITS (no subscriptions, no metering)\nHOSTING IS FREE: publish as many charts as you like — live-updating embeds, stable public URLs,\nfull-resolution PNGs (up to 2400px), with the chartlink badge. Republishing and data updates are\nNEVER metered on any tier (updating is the product). Credits buy PREMIUM, per chart, once: SVG\nexport, custom footer branding, no badge — that slot is premium forever. Packs: 1/$2.90, 10/$19, 50/$59.\nGET /api/billing-info shows credits. To buy: POST /api/checkout-link {\"pack\":\"1\"|\"10\"|\"50\"} returns\na payment URL to hand to your human — it opens straight into checkout (no login; the link can only\nadd credits to this workspace, so sharing it is safe). Upgrade any hosted chart in place with\nPOST /assets/{id}/premium (one credit, same URLs).\n\nCONVENTIONS\nList responses: {data, total, limit, offset}; list rows elide config/data (fetch one asset for full\nbodies). Timestamps are ISO-8601 UTC. Deletes are soft (POST /assets/{id}/restore). Data cap: 2 MB\nper asset — aggregate, don't paginate charts."},"servers":[{"url":"https://chartlink.app"}],"components":{"schemas":{},"parameters":{}},"paths":{"/api/oembed":{"get":{"operationId":"oembed","summary":"oEmbed endpoint for /a/{id} share links","parameters":[{"schema":{"type":"string"},"required":true,"name":"url","in":"query"},{"schema":{"type":"integer","nullable":true},"required":false,"name":"maxwidth","in":"query"},{"schema":{"type":"string","enum":["json"],"default":"json"},"required":false,"name":"format","in":"query"}],"responses":{"200":{"description":"oEmbed rich object"}}}},"/api/billing/webhook":{"post":{"operationId":"paddleWebhook","summary":"Paddle webhook (signature-authenticated)","responses":{"200":{"description":"Processed"}}}},"/api/signup":{"post":{"operationId":"agentSignup","summary":"Instant workspace + API key — no account needed (free plan)","description":"Agent-first onboarding: returns a workspace and its API key in one call. SAVE THE KEY — it is shown exactly once and is the only credential. There are no accounts or logins: the key is the workspace. Pass ref (e.g. \"badge\", \"remix\", \"registry\") to say what brought you here — it is only counted.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":80,"description":"Workspace name, e.g. your product or agent name"},"ref":{"type":"string","maxLength":40,"description":"What brought you here — \"badge\", \"remix\", \"registry\", a directory name. Counted, nothing else."}}}}}},"responses":{"201":{"description":"Workspace + one-time API key"}}}},"/api/whoami":{"get":{"operationId":"whoami","summary":"Verify your key — always call this first","responses":{"200":{"description":"Key + workspace info"}}}},"/api/keys":{"get":{"operationId":"listKeys","summary":"List the workspace's API keys (prefixes only, never secrets)","responses":{"200":{"description":"Keys, newest first"}}},"post":{"operationId":"createKey","summary":"Mint a new API key — the secret is in this response and never again","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":60}},"required":["name"]}}}},"responses":{"201":{"description":"New key incl. its one-time secret"}}}},"/api/keys/{id}":{"delete":{"operationId":"revokeKey","summary":"Revoke a key (irreversible; in-flight requests finish, new ones 401)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Revoked key"}}}},"/api/billing-info":{"get":{"operationId":"getBilling","summary":"This workspace's credits and limits","responses":{"200":{"description":"Plan info"}}}},"/api/checkout-link":{"post":{"operationId":"createCheckoutLink","summary":"Payment link for a credit pack — hand it to your human, no login needed","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"pack":{"type":"string","enum":["1","10","50"],"default":"10"}}}}}},"responses":{"200":{"description":"One-click checkout URL"}}}},"/api/fonts":{"get":{"operationId":"loadFont","summary":"Load a Google Fonts family+weight (fetched and cached server-side) and get its face CSS + metrics","description":"Any Google Fonts family by its exact name. If the family does not ship the exact weight, the nearest one it does ship is loaded and returned as `weight` (requestedWeight says what you asked for). Bundled families (Inter, Source Serif 4, IBM Plex Mono) return their face CSS only — their metrics ship in the client. A family Google does not have is a 404 that says so.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"family","in":"query"},{"schema":{"type":"integer","minimum":100,"maximum":900,"default":400},"required":false,"name":"weight","in":"query"}],"responses":{"200":{"description":"Face CSS and metrics"},"404":{"description":"Font unavailable"}}}},"/api/templates":{"get":{"operationId":"listTemplates","summary":"Charts offered as templates — start a new chart from any of them","description":"Every entry is a published chart whose owner switched showcase on. Each carries urls.png to show a human, and `columns`: the column ids the template's encoding expects, which your data must supply. Then POST /assets with {template: id, data, config: {title: {text}}} — the type and the whole design come from the template; your config is merged on top. The same list, for humans: https://chartlink.app/gallery.","parameters":[{"schema":{"type":"string","description":"Only this asset type"},"required":false,"description":"Only this asset type","name":"type","in":"query"}],"responses":{"200":{"description":"Templates"}}}},"/api/templates/{id}":{"get":{"operationId":"getTemplate","summary":"One template: type, expected columns, urls","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Template"},"404":{"description":"Not a published chart"}}}},"/api/assets":{"get":{"operationId":"listAssets","summary":"List assets (data/config elided; use GET /assets/{id} for full bodies)","parameters":[{"schema":{"type":"string","enum":["line","bar","area","scatter","dumbbell","slope","heatmap","choropleth","table","pie","waterfall"]},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","enum":["draft","published","archived"]},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","description":"Case-insensitive title substring"},"required":false,"description":"Case-insensitive title substring","name":"search","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"integer","nullable":true,"minimum":0,"default":0},"required":false,"name":"offset","in":"query"}],"responses":{"200":{"description":"Assets with total count"}}},"post":{"operationId":"createAsset","summary":"Create a draft asset","description":"Validates config against the type schema and test-renders it, so a 200 means the asset is renderable. Creates a draft by default — iterate with PATCH + the preview URL, then POST /assets/{id}/publish. Send publish: true to do it in one call when the chart is already final.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","enum":["line","bar","area","scatter","dumbbell","slope","heatmap","choropleth","table","pie","waterfall"],"description":"Required unless `template` is given (then it comes from the template)"},"template":{"type":"string","description":"Start from a published chart: its type and whole config are copied (design, fonts, colours, options), your `config` is merged on top (set at least title.text), and your `data` must supply the column ids its encoding expects — GET /templates/{id} lists them, and a mismatch is a 400 that names them. Any published chart works; GET /templates lists the ones offered as starting points."},"title":{"type":"string","description":"Convenience: sets config.title.text"},"config":{"type":"object","additionalProperties":{"nullable":true}},"data":{"type":"object","properties":{"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Column identifier referenced by encodings"},"type":{"type":"string","enum":["string","number","date","boolean"],"description":"date columns parse ISO-ish strings on their own: \"2024\", \"2024-03\", \"2024-03-15\", \"2024-Q2\", full ISO timestamps, or epoch numbers. Anything else needs dateFormat."},"label":{"type":"string","description":"Display label; defaults to id"},"dateFormat":{"type":"string","description":"For date columns whose strings are NOT ISO-ish: a strftime-style parse hint tried first — tokens %Y %m %d %b %B %q %H %M %S, e.g. \"%d/%m/%Y\" for \"15/03/2024\", \"%b %Y\" for \"Mar 2024\". Removes the day/month guess; ISO-ish strings never need it."}},"required":["id","type"],"additionalProperties":false},"minItems":1},"rows":{"type":"array","items":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"nullable":true}]}},"description":"Row-major values, one array per row, order matching columns"}},"required":["columns","rows"],"additionalProperties":false},"brand":{"type":"string","description":"Brand id (brd_...) or slug"},"publish":{"type":"boolean","default":false,"description":"Publish immediately instead of leaving a draft, so the returned url/pngUrl/embedUrl are live. Skip it while you are still iterating — publishing a draft you intend to keep editing just means republishing later."},"note":{"type":"string","description":"Publish note, when publish is true"}},"required":["data"]}}}},"responses":{"201":{"description":"The created asset with ready-to-paste urls"}}}},"/api/assets/{id}/premium":{"post":{"operationId":"upgradeAssetToPremium","summary":"Spend one paid credit: trial-hosted chart becomes premium in place (same URLs)","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Upgraded"}}}},"/api/assets/{id}":{"get":{"operationId":"getAsset","summary":"Get one asset (draft state)","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","enum":["true","false"],"default":"true"},"required":false,"name":"includeData","in":"query"}],"responses":{"200":{"description":"Full asset"}}},"patch":{"operationId":"updateAsset","summary":"Update config (deep-merge) / title / brand","description":"configPatch is DEEP-MERGED: objects merge recursively, arrays replace wholesale, explicit null clears a key — send only what changes. To REMOVE keys (drop an aspect, delete a free text) send `config`: it REPLACES the whole config. Requires expectedVersion; a 409 returns the current row to re-merge onto.","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"expectedVersion":{"type":"integer","minimum":1},"configPatch":{"type":"object","additionalProperties":{"nullable":true},"description":"Deep-merged into config: objects merge, arrays replace, null clears a key"},"config":{"type":"object","additionalProperties":{"nullable":true},"description":"REPLACES the whole config. Use this when a key must go away: a deep-merge cannot express removal except by sending an explicit null, so an editor that holds the full config sends it here."},"title":{"type":"string"},"brand":{"type":"string","nullable":true,"description":"Brand id/slug, or null to use the default brand"},"showcase":{"type":"boolean","description":"Offer this chart as a template: listed in the public gallery and GET /templates once published. Owner keys only."}},"required":["expectedVersion"]}}}},"responses":{"200":{"description":"Updated asset"},"409":{"description":"Version conflict"}}},"delete":{"operationId":"deleteAsset","summary":"Soft-delete (embeds go dark; restorable)","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Deleted"}}}},"/api/assets/{id}/data":{"put":{"operationId":"replaceAssetData","summary":"Replace the data rows (THE core flow: update numbers, embeds update)","description":"Replaces data wholesale. ?publish=auto (default) republishes when the asset is already published, so every embed/PNG serves the new numbers immediately. publish=false stages the data on the draft; publish=true force-publishes even a draft.","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","enum":["auto","true","false"],"default":"auto"},"required":false,"name":"publish","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Column identifier referenced by encodings"},"type":{"type":"string","enum":["string","number","date","boolean"],"description":"date columns parse ISO-ish strings on their own: \"2024\", \"2024-03\", \"2024-03-15\", \"2024-Q2\", full ISO timestamps, or epoch numbers. Anything else needs dateFormat."},"label":{"type":"string","description":"Display label; defaults to id"},"dateFormat":{"type":"string","description":"For date columns whose strings are NOT ISO-ish: a strftime-style parse hint tried first — tokens %Y %m %d %b %B %q %H %M %S, e.g. \"%d/%m/%Y\" for \"15/03/2024\", \"%b %Y\" for \"Mar 2024\". Removes the day/month guess; ISO-ish strings never need it."}},"required":["id","type"],"additionalProperties":false},"minItems":1},"rows":{"type":"array","items":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"nullable":true}]}},"description":"Row-major values, one array per row, order matching columns"}},"required":["columns","rows"],"additionalProperties":false},"mode":{"type":"string","enum":["replace","append"],"default":"replace","description":"append adds the body's rows after the existing ones (columns must match the asset's column ids) — the chunked path for datasets too big for one call. Send publish=false on every chunk but the last."},"note":{"type":"string","description":"Publish note when this triggers a republish"},"expectedVersion":{"type":"integer","minimum":1}},"required":["data"]}}}},"responses":{"200":{"description":"Updated asset (+ publish info when republished)"}}}},"/api/assets/{id}/source":{"put":{"operationId":"setAssetDataSource","summary":"Attach a data source URL — the chart refetches and republishes itself on schedule","description":"Sets {url, format, refresh} and fetches ONCE immediately so you see the parse result now. The scheduled fetcher then keeps the chart current: rows are replaced from the URL (the asset's columns stay authoritative — CSV headers / JSON keys must match column ids or labels), and a published asset republishes after every successful fetch. Refreshes are free — updates are never metered.","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","maxLength":2000,"format":"uri","description":"Public http(s) URL serving CSV or JSON. Fetched on schedule; rows replace the asset's data (columns are fixed by the asset)."},"format":{"type":"string","enum":["auto","csv","json"],"default":"auto","description":"\"auto\" sniffs: JSON when the body parses as JSON, else CSV"},"refresh":{"type":"string","enum":["hourly","daily","weekly"],"default":"daily","description":"Fetch cadence. Published assets republish automatically after a successful fetch."}},"required":["url"],"additionalProperties":false}}}},"responses":{"200":{"description":"Source saved + first fetch outcome (check `fetch.ok`)"}}},"delete":{"operationId":"clearAssetDataSource","summary":"Detach the data source (the chart keeps its current data)","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Source removed"}}}},"/api/assets/{id}/source/fetch":{"post":{"operationId":"fetchAssetDataSource","summary":"Fetch the data source now (on top of the schedule)","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Fetch outcome (check `ok`; `error` is the teachable failure)"}}}},"/api/assets/{id}/edit-link":{"post":{"operationId":"createEditLink","summary":"Mint a no-login tweak link for this chart — hand it to your human","description":"Returns a URL that opens the visual tweak panel for THIS asset only (text, colors, spacing, label nudges) with no login. The secret is stored hashed and shown once; the link can view, patch, replace data, and republish this one chart — never delete it, spend credits, or touch anything else. Offer it whenever the human's request is aesthetic fine-tuning. Links don't expire; revoke via DELETE /assets/{id}/edit-links/{tokenId}.","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"responses":{"201":{"description":"{ url, tokenId } — the url contains the secret, shown once"}}}},"/api/assets/{id}/edit-links":{"get":{"operationId":"listEditLinks","summary":"List this asset's edit links (prefixes only — secrets are never stored)","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Active and revoked links"}}}},"/api/assets/{id}/edit-links/{tokenId}":{"delete":{"operationId":"revokeEditLink","summary":"Revoke one edit link (the URL stops working immediately)","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","pattern":"^etk_"},"required":true,"name":"tokenId","in":"path"}],"responses":{"200":{"description":"Revoked"}}}},"/api/assets/{id}/publish":{"post":{"operationId":"publishAsset","summary":"Publish the draft — snapshots an immutable version and updates every embed","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"note":{"type":"string"}}}}}},"responses":{"200":{"description":"Publish result with embed snippet"}}}},"/api/assets/{id}/unpublish":{"post":{"operationId":"unpublishAsset","summary":"Take the asset offline (embeds return 410)","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Unpublished"}}}},"/api/assets/{id}/duplicate":{"post":{"operationId":"duplicateAsset","summary":"Copy an asset into a new draft (new id, new embed URLs)","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"brand":{"type":"string"}}}}}},"responses":{"201":{"description":"The new draft"}}}},"/api/assets/{id}/restore":{"post":{"operationId":"restoreAsset","summary":"Restore a soft-deleted asset","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Restored"}}}},"/api/assets/{id}/versions":{"get":{"operationId":"listAssetVersions","summary":"Publish history","parameters":[{"schema":{"type":"string","pattern":"^ast_"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Version list (newest first)"}}}},"/api/brands/schema":{"get":{"operationId":"getBrandTokensSchema","summary":"Brand settings JSON Schema — the machine-readable truth for every setting","description":"A brand is DEFAULT SETTINGS: the same shape as a chart's config, minus the two things a brand cannot own — the words, and the type-specific chart options.","responses":{"200":{"description":"JSON Schema for brand settings"}}}},"/api/brands":{"get":{"operationId":"listBrands","summary":"List brands","responses":{"200":{"description":"Brands"}}},"post":{"operationId":"createBrand","summary":"Create a brand","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string","pattern":"^[a-z0-9-]+$","description":"Stable handle agents pass as `brand`"},"name":{"type":"string","minLength":1},"tokens":{"type":"object","additionalProperties":{"nullable":true},"default":{},"description":"Default settings for every chart using this brand — the same shape as a chart config, minus the words and the type-specific chart options. Either shape accepted; GET /brands/schema. A chart's own config overrides these key by key."},"isDefault":{"type":"boolean","default":false}},"required":["slug","name"]}}}},"responses":{"201":{"description":"Created brand"}}}},"/api/brands/{idOrSlug}":{"get":{"operationId":"getBrand","summary":"Get a brand by id or slug","parameters":[{"schema":{"type":"string"},"required":true,"name":"idOrSlug","in":"path"}],"responses":{"200":{"description":"Brand"}}},"patch":{"operationId":"updateBrand","summary":"Update a brand (tokens deep-merge)","parameters":[{"schema":{"type":"string"},"required":true,"name":"idOrSlug","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"expectedVersion":{"type":"integer","minimum":1},"name":{"type":"string"},"tokens":{"type":"object","additionalProperties":{"nullable":true}},"isDefault":{"type":"boolean"}},"required":["expectedVersion"]}}}},"responses":{"200":{"description":"Updated brand"},"409":{"description":"Version conflict"}}},"delete":{"operationId":"deleteBrand","summary":"Soft-delete a brand (assets fall back to the default brand)","parameters":[{"schema":{"type":"string"},"required":true,"name":"idOrSlug","in":"path"}],"responses":{"200":{"description":"Deleted"}}}},"/api/asset-types":{"get":{"operationId":"listAssetTypes","summary":"Available asset types","responses":{"200":{"description":"Types with descriptions"}}}},"/api/asset-types/{type}/schema":{"get":{"operationId":"getAssetTypeSchema","summary":"Config JSON Schema + worked examples for one type","description":"The config is a document made of elements: each element is one node holding its content, its font and its spacing together. Type-specific options live on `chart`, beside its padding.","parameters":[{"schema":{"type":"string"},"required":true,"name":"type","in":"path"}],"responses":{"200":{"description":"Schema and examples"}}}},"/api/feedback":{"post":{"operationId":"submitFeedback","summary":"Stuck or missing a capability? Tell the owner — don't silently give up","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","enum":["bug","feature_request","limitation","documentation_gap","other"]},"title":{"type":"string","minLength":3},"description":{"type":"string","minLength":40},"assetId":{"type":"string"}},"required":["type","title","description"]}}}},"responses":{"201":{"description":"Recorded"}}},"get":{"operationId":"listFeedback","summary":"List submitted feedback","responses":{"200":{"description":"Feedback entries"}}}}}}