DCF Studio — API

Financials in, an auditable assumptions block out — from your own tools.

Back to the app

Drive DCF Studio programmatically

Everything the web app does, over REST. The model extracts and defends assumptions; it never does the arithmetic, so what you get back is a structured ASSUMPTIONS object you feed into your own engine (or into dcf.js, which ships with this app and runs in Node unchanged).

DCF Studio is a modelling aid, not investment advice and not a professional valuation. Everything this API returns is arithmetic on assumptions supplied by you or by a language model, and a different, equally defensible set of assumptions gives a different answer. Do not present its output as a price target or a recommendation.

Base URL and envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api. There is no /apps/{slug}/ segment — the slug is bound to your token when it is minted at /guest. Getting that wrong returns 404 not_found.

POST /guest GET /me POST /estimate POST /run POST /run-stream GET /jobs/{job_id}

Every response is one of two shapes: {"data": {...}} on success, {"error": {"code": "...", "message": "..."}} on failure. HTTP status and error.code always agree.

StatusCodeWhat it means
400validation_errorThe body was not an object, or a field was not a string.
401unauthorizedMissing, malformed or revoked bearer token.
402payment_requiredBalance below min_credits. Call /estimate and /me first - that pair is free and is exactly what the app's preflight does.
404not_foundAlmost always the route: there is no /apps/{slug}/ segment. The slug is bound to the token at /guest.
409conflictAn Idempotency-Key replay whose in-flight original has not finished.
429rate_limitedBack off and retry; the data endpoints allow 120 requests/minute.
503unavailableUpstream model capacity. Retry with the same Idempotency-Key.

1. A token and a tiny client

Grab a token from the token page — it reads the one this browser already holds, shows whether it is a personal or guest token, and gives you a SKILLSAFE_TOKEN shell export to copy. You never need the DevTools console. Fully scripted callers can mint a guest token with POST /guest instead.

A guest token can call /me and the free /estimate. Metered runs need a personal token so they bill your account.

2. Who am I, and what is my balance?

GET /me is free. Compare credits against min_credits from step 3 before you ever call /run; that pair is the whole credit preflight, and it is what stops a 402 arriving after you have already sent a 40 KB filing.

3. Estimate — free, no job, no charge

The request body is the input object directly, not {"input": {...}}. These are the exact fields the web app sends:

FieldTypeRequiredNotes
companystringnoThe company name or ticker as the user typed it. May be empty - the model prefers the name found in financials when they disagree.
financialsstringyes*The pasted material: historical revenue and operating figures, share count, share price, debt, cash, tax rate and market data (risk-free yield, beta, bond yield). Messy is fine. The web app clips this to 40,000 characters from the middle, keeping both ends and inserting a bracketed marker naming how much was removed.
assumptionsstringnoA JSON assumptions object the caller already has, passed as a string. Treated as the user's own view: kept unless the financials contradict it, with any override reported under CHECKS. Clipped to 8,000 characters.
notesstringnoFree-prose guidance ("management guides 9-11%", "use 7 years"). Clipped to 2,000 characters.
retry_notestringnoSent only by the app's automatic one-shot reformat retry, describing what was wrong with the shape of the previous reply. If you send it yourself, use a different Idempotency-Key from the first attempt - a replayed key returns the original job, not the reformatted one.

* Either financials or assumptions must be non-empty. With neither, the model returns VERDICT: Not enough to value and a placeholder assumptions block rather than inventing a company.

Assert on this response before shipping anything: model is gpt-5.6-terra, model_alias is gpt-terra, markup_bps is 1000. Present hold_credits as reserved, never as the price — the settled charge is usually far lower.

4. Run and poll — metered

Send an Idempotency-Key on every run. Derive it from a hash of the input so that a retry of the same submission joins the original job instead of billing twice. If you resubmit with a retry_note to fix a malformed reply, that is a different request and needs a different key (the app uses the same hash with a -reformat suffix) — replaying the first key returns the original job and deduped: true, i.e. the same broken answer.

The terminal job carries output.output (the reply text), charged_credits and truncated. When truncated is true the output cap was reached: show it as incomplete rather than as a finished model.

5. Stream it — metered

/run-stream takes the same body and the same key and emits SSE. The section headings arrive in contract order, which is what the web app uses to advance its progress stages. Keep the accumulated text even if the stream dies: a partial reply usually still contains a complete ASSUMPTIONS block, and the app repairs a block cut mid-JSON by closing what was open — never by guessing missing values.

6. The output contract

The reply is plain text in a fixed section order. This is the contract the app's parseResult decodes, taken from the parser rather than from intent: COMPANY: then VERDICT: (exactly one of Well grounded, Check the assumptions, Not enough to value) then SUMMARY:, then a line reading exactly ASSUMPTIONS: followed by a single JSON object, then ANALYSIS:, CHECKS: and NOTES:.

COMPANY: Havenmark Fluid Systems (HVMK)
VERDICT: Well grounded
SUMMARY: All load-bearing inputs are in the 10-K excerpt and market screen.

ASSUMPTIONS:
{
  "company": "Havenmark Fluid Systems", "ticker": "HVMK", "currency": "USD",
  "share_price": 28.40, "shares_out_m": 58.4,
  "total_debt_m": 620.0, "cash_m": 145.0,
  "tax_rate": 0.24,
  "base_revenue_m": 1071.0,
  "years": 5,
  "da_pct_rev": 0.046, "capex_pct_rev": 0.055, "nwc_pct_delta_rev": 0.10,
  "wacc_inputs": { "risk_free": 0.042, "beta": 1.30, "erp": 0.055,
                   "pretax_cost_debt": 0.058 },
  "scenarios": {
    "bear": { "growth": [0.07,0.06,0.05,0.04,0.03],
              "ebit_margin": [0.148,0.148,0.148,0.148,0.148],
              "terminal_growth": 0.02, "wacc_delta": 0.01 },
    "base": { "growth": [0.10,0.085,0.07,0.06,0.05],
              "ebit_margin": [0.148,0.150,0.152,0.153,0.155],
              "terminal_growth": 0.025, "wacc_delta": 0 },
    "bull": { "growth": [0.11,0.10,0.09,0.07,0.06],
              "ebit_margin": [0.150,0.155,0.160,0.165,0.170],
              "terminal_growth": 0.03, "wacc_delta": -0.005 }
  }
}

ANALYSIS:
**Bear:** ... **Base:** ... **Bull:** ... **Key drivers:** ...

CHECKS:
- Terminal growth vs. WACC: ...

NOTES:
**Extracted:**
- Revenue base $1,071.0M - "Net revenue ... 1,071.0" FY2025 column.
**Assumed:** None.
**Ignored:**
- Buyback authorization - a capital-return decision, not an operating driver.

Valuation confidence: 90%, complete four-year revenue/margin history.
A reply whose ASSUMPTIONS block fails validation fails the whole parse. The bands the engine enforces: years is an integer 3–10; growth and ebit_margin have exactly years entries in every scenario; tax_rate 0–0.6; terminal_growth −0.02–0.06; beta 0.1–3.5. A scenario whose terminal growth is not at least 25 bp below its WACC is reported as not computable rather than valued — the perpetuity diverges there, and a confident wrong number is worse than none.

7. Computing the model yourself

dcf.js is the whole valuation engine and is served from this origin. It exports under module.exports in Node and window.DCF in a browser, so the numbers you compute are identical to the ones the page shows.