Authentication

Every request (except /v1/auth/register and /v1/auth/login) requires a bearer token issued at registration or login.

Authorization: Bearer <access_token>

Tokens are short-lived JWTs. Their TTL is set by JWT_ACCESS_TTL_SECONDS in your environment. All data is scoped to the organization that owns the token.

Errors

Every error response follows the same envelope.

{
  "error": {
    "type": "invalid_request",
    "code": "missing_field",
    "message": "name is required.",
    "param": "name",
    "request_id": "req_01j..."
  }
}
StatusWhen
200Successful read
201Resource created
202Ingest accepted and completed
204Successful delete (no body)
400Malformed JSON
401Missing or invalid bearer token
404Resource not found (or not owned by your org)
409Idempotency key reuse with different body
422Validation error (missing required field, bad subtype, etc.)
502Upstream dependency (e.g., Stripe) unavailable
500Unexpected server error

All list endpoints use cursor-based pagination.

ParameterDefaultDescription
limit20Max results to return (max 100)
starting_afterID of the last item from the previous page

Response shape for all lists:

{
  "data": [ /* array of objects */ ],
  "has_more": true
}
Idempotency

All state-mutating endpoints (POST, PATCH) support an idempotency key. Send the same key with the same body and you get back the original response. Sending the same key with a different body returns 409 conflict.

Idempotency-Key: my-unique-request-id-123
Metric Definitions

All metrics are computed over a rolling 30-day window ending on as_of.

FieldDefinition
gross_revenue_cents_30dSum of all revenue transactions (excluding refunds) in window
refunds_cents_30dSum of refund transactions (negative value)
net_revenue_cents_30dgross_revenue + refunds
mrr_centsRecurring revenue only — manual recurring events + Stripe subscription_invoice events
arr_centsmrr_cents × 12
burn_cents_30dtotal_expenses - net_revenue
recurring_burn_cents_30drecurring_expenses - net_revenue. Used for runway.
one_time_expenses_cents_30dSum of one_time_expense transactions
runway_monthscash_on_hand_cents / recurring_burn_cents_30d when burn is positive; otherwise null
cash_on_hand_centsSet on the workspace via PATCH. Not derived from transactions.

Auth
POST /v1/auth/register

Creates a new organization and returns an access token.

Request body
{
  "org_name": "Acme Inc",
  "email":    "ceo@acme.com",
  "password": "hunter2"
}
Response — 201
{
  "org": {
    "id":         "org_01j...",
    "name":       "Acme Inc",
    "email":      "ceo@acme.com",
    "created_at": "2026-03-01T00:00:00+00:00"
  },
  "access_token": "eyJ..."
}
POST /v1/auth/login

Returns an access token for an existing organization.

Request body
{
  "email":    "ceo@acme.com",
  "password": "hunter2"
}
Response — 200
{
  "org": {
    "id":         "org_01j...",
    "name":       "Acme Inc",
    "email":      "ceo@acme.com",
    "created_at": "2026-03-01T00:00:00+00:00"
  },
  "access_token": "eyJ..."
}
Workspaces

A workspace represents one business or product. All transactions, metrics, forecasts, scenarios, and alerts are scoped to a workspace. cash_on_hand_cents drives runway calculations.

POST /v1/workspaces

Creates a new workspace. Supports idempotency.

Request body
{
  "name":               "Acme Corp",          // required
  "cash_on_hand_cents": 5000000              // optional, in cents ($50,000)
}
Response — 201
{
  "id":                 "ws_01j...",
  "org_id":             "org_01j...",
  "name":               "Acme Corp",
  "cash_on_hand_cents": 5000000,
  "created_at":         "2026-03-01T00:00:00+00:00"
}
GET /v1/workspaces

Lists all workspaces for your organization. Paginated.

Query parameters
limit=20&starting_after=ws_01j...
Response — 200
{
  "data": [ /* workspace objects */ ],
  "has_more": false
}
GET /v1/workspaces/{workspace_id}

Returns a single workspace.

Response — 200
{
  "id":                 "ws_01j...",
  "org_id":             "org_01j...",
  "name":               "Acme Corp",
  "cash_on_hand_cents": 5000000,
  "created_at":         "2026-03-01T00:00:00+00:00"
}
PATCH /v1/workspaces/{workspace_id}

Updates workspace name and/or cash on hand. Only fields included in the request body are changed. Supports idempotency.

Request body
{
  "name":               "Acme Corp",    // optional
  "cash_on_hand_cents": 7500000         // optional; pass null to clear
}
Response — 200
// Updated workspace object
DELETE /v1/workspaces/{workspace_id}

Permanently deletes the workspace and all associated transactions, forecasts, scenarios, and alerts. Returns 204 No Content.

Ingest

Push expense or revenue events into a workspace. All ingest endpoints are idempotent by default (use external_id on each line item to deduplicate across calls). All amounts are in cents and must be positive integers for expenses and revenue; refunds are stored internally as negative.

POST /v1/workspaces/{workspace_id}/ingest/expenses

Ingests one or more expense events. The subtype field determines how expenses affect runway: recurring_expense items factor into recurring_burn_cents_30d and runway; one_time_expense items factor into burn_cents_30d only.

Request body
{
  "expenses": [
    {
      "subtype":      "recurring_expense",  // "recurring_expense" | "one_time_expense"
      "amount_cents": 120000,
      "currency":     "USD",                // default "USD"
      "occurred_at":  "2026-02-01",
      "vendor":       "AWS",                // optional
      "external_id":  "aws-feb-2026"        // optional, used for dedup
    }
  ]
}
Response — 202
{
  "ingest_id":  "ing_01j...",
  "status":     "completed",
  "inserted":   1,
  "duplicates": 0
}
POST /v1/workspaces/{workspace_id}/ingest/revenue

Ingests one or more manual revenue events. Use subtype: "recurring" for subscription or retainer revenue — it will contribute to mrr_cents. Use subtype: "one_time" for non-recurring income.

Request body
{
  "revenue": [
    {
      "subtype":      "recurring",     // "recurring" | "one_time"
      "amount_cents": 250000,
      "currency":     "USD",
      "occurred_at":  "2026-02-01",
      "description":  "Feb subscription",  // optional
      "external_id":  "sub-feb-2026"        // optional, used for dedup
    }
  ]
}
Response — 202
{
  "ingest_id":  "ing_01j...",
  "status":     "completed",
  "inserted":   1,
  "duplicates": 0
}
POST /v1/workspaces/{workspace_id}/ingest/stripe

Pulls all paid invoices, standalone charges, succeeded refunds, and subscription metadata directly from the Stripe API using your secret key. Pagination is handled automatically. Re-running the call is safe — previously ingested objects are skipped.

Note: Invoice-linked charges are intentionally skipped to avoid double-counting revenue already captured by the invoice.
Request body
{
  "stripe_api_key": "sk_live_..."
}
Response — 202
{
  "ingest_id":  "ing_01j...",
  "status":     "completed",
  "inserted":   147,
  "duplicates": 12
}
Error codeMeaning
invalid_stripe_api_key422 — Stripe rejected the key as invalid
stripe_unavailable502 — Stripe API is unreachable
Metrics
GET /v1/workspaces/{workspace_id}/metrics

Returns computed CFO metrics for a rolling 30-day window ending on as_of (defaults to today). All calculations are derived deterministically from ingested transactions.

Query parameters
as_of=2026-02-28   // YYYY-MM-DD, defaults to today
Response — 200
{
  "as_of":                       "2026-02-28",
  "currency":                    "USD",
  "gross_revenue_cents_30d":     2400000,
  "refunds_cents_30d":           -80000,
  "net_revenue_cents_30d":       2320000,
  "mrr_cents":                   2150000,
  "arr_cents":                   25800000,
  "burn_cents_30d":              480000,
  "recurring_burn_cents_30d":    350000,
  "one_time_expenses_cents_30d": 130000,
  "cash_on_hand_cents":          5000000,
  "runway_months":               14.29,
  "warnings": []
}

warnings may contain { "code": "missing_cash", "message": "..." } when cash_on_hand_cents is not set on the workspace (runway will be null).

Forecasts

A forecast projects MRR, cash, and runway month-by-month from as_of for horizon_months months. Multiple variants (base/best/worst) can be computed in a single call. starting_cash_cents is automatically read from the workspace — set it with PATCH /v1/workspaces/{id} first.

POST /v1/workspaces/{workspace_id}/forecasts

Creates and saves a new forecast. Supports idempotency.

Request body
{
  "name":           "Q2 2026 Base Plan",    // optional label
  "as_of":          "2026-02-28",           // defaults to today
  "horizon_months": 12,                     // required
  "assumptions": {
    "mrr_growth_pct":       6,              // monthly MRR growth %
    "monthly_logo_churn_pct": 2,            // monthly logo churn %
    "gross_margin_pct":     85             // gross margin %
  },
  "variants": {
    "base":  {},
    "best":  { "mrr_growth_pct": 9,  "monthly_logo_churn_pct": 1 },
    "worst": { "mrr_growth_pct": 2,  "monthly_logo_churn_pct": 4 }
  }
}
Response — 201
{
  "id":              "fc_01j...",
  "workspace_id":    "ws_01j...",
  "org_id":          "org_01j...",
  "name":            "Q2 2026 Base Plan",
  "as_of":           "2026-02-28",
  "horizon_months":  12,
  "assumptions":     { /* merged assumptions */ },
  "series": {
    "months": [ "2026-03", "2026-04", /* ... */ ],
    "base": {
      "mrr_cents":      [ 2279000, 2415740, /* ... */ ],
      "cash_cents":     [ 4700000, 4400000, /* ... */ ],
      "runway_months":  [ 13.43,   12.57,   /* ... */ ]
    },
    "best":  { /* same shape */ },
    "worst": { /* same shape */ }
  },
  "created_at": "2026-03-01T00:00:00+00:00"
}
GET /v1/workspaces/{workspace_id}/forecasts

Returns a lightweight list of forecasts (no series or assumptions). Paginated. Use GET by ID to retrieve full data.

Response — 200
{
  "data": [
    {
      "id":             "fc_01j...",
      "name":           "Q2 2026 Base Plan",
      "as_of":          "2026-02-28",
      "horizon_months": 12,
      "created_at":     "2026-03-01T00:00:00+00:00"
    }
  ],
  "has_more": false
}
GET /v1/workspaces/{workspace_id}/forecasts/{forecast_id}

Returns the full forecast including assumptions and all variant series.

Response — 200
// Full forecast object (same shape as the create 201 response)
Scenarios

A scenario re-runs a saved forecast with a modified assumption (delta) and stores the resulting series alongside an impact summary. Use scenarios to answer what-if questions without overwriting your baseline.

POST /v1/workspaces/{workspace_id}/scenarios

Creates a what-if scenario against a baseline forecast. Supports idempotency.

Request body
{
  "name":                 "10% price increase",  // optional
  "baseline_forecast_id": "fc_01j...",           // required
  "delta": {
    "type": "price_change",
    "pct":  0.10
  }
}

// Other delta examples:
{ "type": "churn_change", "delta_pp": 1.5 }          // +1.5pp churn
{ "type": "hire", "monthly_cost_cents": 1200000,
  "start_month": "2026-04" }                         // new hire cost
Response — 201
{
  "id":                   "sc_01j...",
  "workspace_id":         "ws_01j...",
  "org_id":               "org_01j...",
  "name":                 "10% price increase",
  "baseline_forecast_id": "fc_01j...",
  "delta":   { "type": "price_change", "pct": 0.10 },
  "impact": {
    "final_cash_delta_cents": 980000,
    "final_mrr_cents":        3150000
  },
  "series": {
    "months": [ /* ... */ ],
    "scenario": {
      "mrr_cents":     [ /* ... */ ],
      "cash_cents":    [ /* ... */ ],
      "runway_months": [ /* ... */ ]
    }
  },
  "created_at": "2026-03-01T00:00:00+00:00"
}
GET /v1/workspaces/{workspace_id}/scenarios

Returns a lightweight list of scenarios (includes delta and impact, excludes series). Paginated. Use GET by ID to retrieve full series data.

Response — 200
{
  "data": [
    {
      "id":                   "sc_01j...",
      "name":                 "10% price increase",
      "baseline_forecast_id": "fc_01j...",
      "delta":  { "type": "price_change", "pct": 0.10 },
      "impact": {
        "final_cash_delta_cents": 980000,
        "final_mrr_cents":        3150000
      },
      "created_at": "2026-03-01T00:00:00+00:00"
    }
  ],
  "has_more": false
}
GET /v1/workspaces/{workspace_id}/scenarios/{scenario_id}

Returns the full scenario including the projected series.

Response — 200
// Full scenario object (same shape as the create 201 response)
Alerts
GET /v1/workspaces/{workspace_id}/alerts

Evaluates alert rules against current metrics and returns any active alerts. Results are computed fresh on each call and persisted, replacing any previously stored alerts.

Query parameters
as_of=2026-02-28   // YYYY-MM-DD, defaults to today
Response — 200
{
  "data": [
    {
      "id":           "al_01j...",
      "type":         "runway_low",
      "severity":     "critical",
      "message":      "Runway is below 6 months (2.5).",
      "payload":      { "runway_months": 2.5 },
      "workspace_id": "ws_01j...",
      "org_id":       "org_01j...",
      "created_at":   "2026-03-01T00:00:00+00:00"
    }
  ]
}
Alert typeSeverityFires when
runway_low warn / critical Runway < 6 months (critical if < 3 months)
mrr_decline warn MRR fell > 8% vs prior 30 days — only fires when the workspace has recurring revenue
revenue_decline warn Net revenue fell > 8% vs prior 30 days
refund_spike warn Refund volume is more than 2× the previous 30-day period
AI Suggestions
POST /v1/workspaces/{workspace_id}/ai/suggestions

Generates an AI-powered advisory memo grounded entirely in computed metrics and alerts. Requires HUGGINGFACE_API_KEY to be configured. If the AI provider is unavailable, the endpoint returns a fallback response with the raw metrics and alerts so callers can still display data.

Query parameters
as_of=2026-02-28   // YYYY-MM-DD, defaults to today
Response — 200 (AI available)
{
  "workspace_id": "ws_01j...",
  "as_of":        "2026-02-28",
  "summary":      "Cash position is stable with 14.3 months runway...",
  "suggestions": [ "Consider increasing the marketing budget..." ],
  "risks":       [ "Refund volume rose 2.3x last month." ],
  "grounding": {
    "workspace_name": "Acme Corp",
    "alert_count":    1,
    "alerts":         [ /* alert objects */ ],
    "metrics":        { /* metrics object */ }
  },
  "provider":     "huggingface",
  "model":        "mistralai/Mistral-7B-Instruct-v0.2",
  "ai_available": true
}
Response — 200 (AI unavailable / fallback)
{
  "workspace_id": "ws_01j...",
  "as_of":        "2026-02-28",
  "summary":      "AI is currently unavailable. Returning computed metrics and alerts only.",
  "suggestions": [],
  "risks":       [],
  "grounding":   { /* metrics + alerts */ },
  "provider":     "fallback",
  "model":        null,
  "ai_available": false,
  "ai_error": {
    "type":    "configuration_error",
    "code":    "huggingface_not_configured",
    "message": "HUGGINGFACE_API_KEY is not set."
  }
}
Advisory only. AI output is narrative text grounded on already-computed metrics and alerts. The LLM does not perform any calculations.
End-to-end example

Register, set up a workspace, ingest data, and pull metrics in one shell session.

# 1. Register
TOKEN=$(curl -s -X POST https://mycfo.vercel.app/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"org_name":"Acme","email":"ceo@acme.com","password":"hunter2"}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

# 2. Create workspace
WS=$(curl -s -X POST https://mycfo.vercel.app/v1/workspaces \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Corp","cash_on_hand_cents":5000000}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")

# 3. Ingest expenses
curl -s -X POST https://mycfo.vercel.app/v1/workspaces/$WS/ingest/expenses \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expenses":[
    {"subtype":"recurring_expense","amount_cents":120000,"occurred_at":"2026-02-01","vendor":"AWS"},
    {"subtype":"one_time_expense","amount_cents":500000,"occurred_at":"2026-02-15","vendor":"Design contractor"}
  ]}'

# 4. Ingest recurring revenue
curl -s -X POST https://mycfo.vercel.app/v1/workspaces/$WS/ingest/revenue \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"revenue":[
    {"subtype":"recurring","amount_cents":250000,"occurred_at":"2026-02-01","description":"Feb subscriptions"}
  ]}'

# 5. Pull metrics
curl -s "https://mycfo.vercel.app/v1/workspaces/$WS/metrics?as_of=2026-02-28" \
  -H "Authorization: Bearer $TOKEN"

# 6. Run a 12-month forecast
FC=$(curl -s -X POST https://mycfo.vercel.app/v1/workspaces/$WS/forecasts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Base plan","horizon_months":12,
       "assumptions":{"mrr_growth_pct":6,"monthly_logo_churn_pct":2,"gross_margin_pct":85},
       "variants":{"base":{},"best":{"mrr_growth_pct":9},"worst":{"mrr_growth_pct":2}}}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")

# 7. What-if: 10% price increase
curl -s -X POST https://mycfo.vercel.app/v1/workspaces/$WS/scenarios \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"name\":\"Price +10%\",\"baseline_forecast_id\":\"$FC\",\"delta\":{\"type\":\"price_change\",\"pct\":0.10}}"

# 8. Check alerts
curl -s "https://mycfo.vercel.app/v1/workspaces/$WS/alerts" \
  -H "Authorization: Bearer $TOKEN"