Furlen
Turns public data (World Bank, IMF, FRED, Eurostat, SEC filings) or your own CSV into an animated chart video, recomputing every figure from the source rows before export.
Documentation
Developers
Render verified data videos from code.
One call runs the whole pipeline: profile → insights → AI story → claim verification → render. Plan limits (watermark, resolution, render minutes) apply exactly as in-app.
You'll need a free account to mint a key: sign in, then create one under Settings → API keys. Keys are shown once — store yours securely.
POST /api/v1/renders
CSV in, queued render out. Rate limit: 10 renders/min per workspace.
Copy
curl -X POST https://furlen.pro/api/v1/renders
-H "Authorization: Bearer sg_live_..."
-H "Content-Type: application/json"
-d '{
"csv": "month,revenue\n2025-01,120000\n2025-02,145000\n2025-03,210000",
"audience": "investor",
"outputFormat": "video_16_9",
"format": "mp4",
"resolution": "1080p"
}'
audience: investor · executive · linkedin · youtube · internal_team · client_report · student — outputFormat: video_16_9 · video_9_16 · video_1_1 — format: mp4 · gif — resolution: 720p · 1080p · 4K (capped by plan).
GET /api/v1/renders/:id
Poll every few seconds; renders typically take 30–90s. downloadUrl appears when completed.
A render moves through queued → running → completed, or ends failed / cancelled. Treat any unknown status as “still working”. Example bodies:
Copy
// queued { "renderId": "3f9c…-uuid", "status": "queued", "progress": 0 }
// running — progress is a percentage, 0..100 { "renderId": "3f9c…-uuid", "status": "running", "progress": 62 }
// completed { "renderId": "3f9c…-uuid", "status": "completed", "progress": 100, "format": "mp4", "resolution": "1080p", "downloadUrl": "/api/render-jobs/3f9c…-uuid/download" }
// failed — 'error' carries the reason; safe to retry { "renderId": "3f9c…-uuid", "status": "failed", "progress": 0, "error": "Render worker error — safe to retry", "downloadUrl": null }
Idempotency, limits & headers
Send an Idempotency-Key on POST so a retried request never double-renders (and never double-charges render minutes) — the same key returns the same job.
Copy
POST /api/v1/renders Idempotency-Key: 9f2c-your-unique-key
// Rate-limit headers on every response: X-RateLimit-Limit: 10 X-RateLimit-Remaining: 7 X-RateLimit-Reset: 1752531600 // unix seconds Retry-After: 42 // present on 429
Limits: CSV/JSON body up to 5 MB · 10 renders/min per workspace · resolution capped by plan (720p Free, 1080p Creator, 4K Pro+). Invalid input returns 400 invalid_request with a message naming the offending field.
Errors
Every error returns error (a human-readable message) and code (stable — parse this one). Messages get reworded; codes do not.
Copy
401 { "error": "Invalid or missing API key.", "code": "unauthorized" } 403 { "error": "This API key does not have the 'renders:write' scope.", "code": "insufficient_scope" } 402 { "error": "This format requires a higher plan.", "code": "plan_gate" } 422 { "error": "No story-worthy insights found in this data.", "code": "no_insights" } 429 { "error": "Rate limit exceeded (10/min).", "code": "rate_limited" }
401, 403 and 402 are terminal — fix the key, the scope, or the plan. 429 is safe to retry once Retry-After has elapsed. 422 means the data itself can't carry a story; retrying the same CSV will fail identically.
API key scopes
Keys are scoped. Give a CI job a key that can only start renders, and it cannot pull down your exports if it leaks.
| Scope | Allows |
|---|---|
| renders:write | Start renders |
| renders:read | Check render status |
| exports:read | Download finished exports |
| data:read | Fetch public datasets |
Keys created before scopes existed keep all three — nothing broke when this shipped. Choose scopes when you create a key in Settings. A call missing a scope returns 403 insufficient_scope, never 401: the key is valid, it just may not do that.
Rotating a key
Rotation mints a replacement and leaves the old key working for a grace period (default 24h, max 168h), so you can deploy the new one without downtime. The replacement inherits the original's scopes.
Copy
Requires your signed-in session cookie (run from the dashboard, or copy the
request from DevTools while signed in) — an API key cannot authorize this call.
curl -X POST https://furlen.pro/api/keys//rotate
-H 'cookie: '
-H 'content-type: application/json'
-d '{"graceHours": 24}'
→ { "key": "sg_live_…", "oldKeyExpiresAt": "2026-07-16T…Z" }
Rotation is a signed-in account action — an API key cannot rotate itself, or a leaked key could mint a fresh one and survive revocation. If a key may have leaked, revoke it instead: revocation is immediate and has no grace period.
OpenAPI spec
/api/openapi.json — OpenAPI 3.1, generated from the same constants the API enforces, so it cannot drift into describing something we do not do.
Copy
npx openapi-typescript https://furlen.pro/api/openapi.json -o furlen.d.ts
Node & Python
There is no Furlen SDK package — the API is two endpoints, and a dependency you have to trust is a poor trade for the ~20 lines below. Both examples poll, because Furlen has no webhooks (see below).
Copy
// Node 18+ — no dependencies.
const KEY = process.env.FURLEN_API_KEY;
const h = { authorization: Bearer ${KEY}, 'content-type': 'application/json' };
const start = await fetch('https://furlen.pro/api/v1/renders', {
method: 'POST',
headers: h,
body: JSON.stringify({ csv: 'month,revenue\n2024-01,100\n2024-02,250' }),
});
if (!start.ok) throw new Error(${start.status}: ${(await start.json()).code});
const { renderId } = await start.json();
// Poll. 60/min is the ceiling, so ~2s is comfortable.
// On 429, WAIT for Retry-After (plus jitter) — a bare continue would hammer the limiter.
let attempts = 0;
for (;;) {
await new Promise((r) => setTimeout(r, 2000));
const res = await fetch(https://furlen.pro/api/v1/renders/${renderId}, { headers: h });
if (res.status === 429) {
if (++attempts > 10) throw new Error('rate limited too long — giving up');
const wait = Number(res.headers.get('retry-after') ?? 5) * 1000 + Math.random() * 500;
await new Promise((r) => setTimeout(r, wait));
continue;
}
attempts = 0;
if (!res.ok) throw new Error(polling failed: ${res.status});
const job = await res.json();
if (job.status === 'completed') { console.log(job.downloadUrl); break; }
if (job.status === 'failed') throw new Error(job.error);
if (job.status === 'cancelled') throw new Error('render cancelled');
}
Copy
Python 3.9+ — pip install requests
import os, time, requests
KEY = os.environ["FURLEN_API_KEY"] H = {"authorization": f"Bearer {KEY}"}
r = requests.post("https://furlen.pro/api/v1/renders", headers=H, json={"csv": "month,revenue\n2024-01,100\n2024-02,250"}) r.raise_for_status() render_id = r.json()["renderId"]
while True: time.sleep(2) res = requests.get(f"https://furlen.pro/api/v1/renders/{render_id}", headers=H) if res.status_code == 429: time.sleep(int(res.headers.get("Retry-After", 5))) continue res.raise_for_status() job = res.json() if job["status"] == "completed": print(job["downloadUrl"]) break if job["status"] == "failed": raise RuntimeError(job["error"]) if job["status"] == "cancelled": raise RuntimeError("render cancelled")
Downloading the finished export needs a key with exports:read.
What Furlen does not have
Worth knowing before you build:
- No webhooks. Renders are polled — the loops above are the supported pattern. If callbacks would change your design, email support@furlen.pro.
- No sandbox or test mode. Every key is
sg_live_, and every render is a real render against your quota. Test on the free plan — previews are unlimited and watermarked. - No published SDK. Copy the examples above, or generate a typed client from the OpenAPI spec.
- No status page. /api/health answers
{ status: "ok" }to anyone and returns the config probe — wired services and warnings — only to a request carrying an API key. Either way it is a reachability check, not uptime history.
Is there an MCP server for making charts?
Yes. furlen-mcp is published to the official Model Context Protocol registry as io.github.amitsha86/furlen-mcp and to npm. It gives Claude, Cursor, or any MCP client three tools that turn a table of numbers into an animated chart video — and it recomputes every figure in the narration against your rows before it will export anything. The server is open source: github.com/amitsha86/furlen-mcp.
What can an agent actually do with it?
Three things. furlen_public_data (from v0.1.2) takes a plain-English request — “India GDP over the last 10 years”, “compare US and China population”, “Apple revenue over 6 years” — and returns real rows from the World Bank, IMF, FRED, Eurostat, UN Comtrade, WHO or company filings, with a ready-made csv string and provenance naming the exact indicator. furlen_render takes CSV — that one, or the agent’s own — and runs the whole pipeline: profile, find the insights, write the story, verify every claim, render. It returns a renderId; you can set the audience, aspect ratio (16:9, 9:16 or 1:1), mp4 or gif, and resolution. Then furlen_render_status polls that id for a download URL. Renders usually take 30–90 seconds, so poll rather than block.
Can an agent chart World Bank data without a spreadsheet?
Yes — that is what furlen_public_data is for, and it is also a plain HTTP endpoint if you would rather not use MCP: POST /api/v1/data with { "prompt": "…" } and the data:read scope. It resolves the request through the same adapters the studio uses, so it inherits the same refusals: ask for something no connected source publishes and you get a 422 with code: "subject_unsupported" naming the subject, not a plausible-looking chart. Rate limited to 6 requests a minute — lower than the render endpoint, because each call fans out to statistical agencies that rate-limit us in turn.
Copy
curl -X POST https://furlen.pro/api/v1/data
-H "Authorization: Bearer sg_live_..."
-H "Content-Type: application/json"
-d '{"prompt": "India GDP over the last 10 years"}'
What does “verified” mean over MCP?
The same thing it means in the app, because it is the same server-side gate. Every numeric claim the AI writes is recomputed from the rows you passed in, and a claim that doesn’t reconcile blocks the export instead of shipping. That matters more through an agent than through a UI: nobody is watching the intermediate output, so the check has to be the thing that refuses rather than a human noticing. You can watch it happen on the proof page.
How do you install it?
Create an API key in Settings, then add the block below to claude_desktop_config.json, .cursor/mcp.json, or your client’s equivalent. There is nothing to install first — npx fetches it.
Copy
{ "mcpServers": { "furlen": { "command": "npx", "args": ["-y", "furlen-mcp"], "env": { "FURLEN_API_KEY": "sg_live_..." } } } }
What can’t it do yet?
It can’t upload a file — data arrives as CSV text in the request or by naming a public dataset, not as a spreadsheet attachment. Rendering is asynchronous, so there is no “give me a chart back now” call. And the sources are the sources: if no connected provider publishes the figure, the answer is a refusal rather than an estimate. It needs an API key with the right scopes, and your plan’s limits on resolution, watermarking and render minutes apply exactly as they do in the app. MIT licensed.