Overview
Quickstart
Ship a live market-data call in under a minute. One tengu key unlocks the FIRM data API
Ship a live market-data call in under a minute. One tengu_ key unlocks the FIRM data API
(firm.wealthnow.io) and the Brain agent API (brain.wealthnow.io) — same credential, same credit wallet.
| Time | ~60 seconds to first 200 |
| You need | A Wealthnow workspace and a tengu_ key from Overview |
| You get | Authenticated REST access, MCP-ready credentials, shared billing |
Agents & SDKs. The same key works over MCP and generated SDKs — no separate token.
Surfaces#
| Surface | Base URL | Auth | Billing |
|---|---|---|---|
| FIRM data API | https://firm.wealthnow.io | Authorization: Bearer tengu_… (or X-API-Key) | Flat credits per call |
| Brain agent API | https://brain.wealthnow.io/v1 | Authorization: Bearer tengu_… | Credits per token (Enterprise) |
Every FIRM response is a JSON envelope: { "ok": true, "timestamp": "<ISO-8601>", …data }. Non-2xx responses do not consume credits.
1. Get your key#
- Open Overview in the dashboard and create or reveal your key (
tengu_…). Copy it once; store it like a password. - Export it for the snippets below:
export TENGU_API_KEY="tengu_..."2. Authenticate#
Prefer the Authorization header (OpenAI/Anthropic SDKs expect it). Authorization wins when both headers are set:
Authorization: Bearer tengu_…— recommendedX-API-Key: tengu_…
Avoid ?api_key= in URLs — credentials leak into logs, history, and proxies.
1curl -H "Authorization: Bearer $TENGU_API_KEY" \2 "https://firm.wealthnow.io/api/market/quote/AAPL"3. First data call (Free plan)#
market_data is included on Free — 1 credit per successful quote:
1curl --fail-with-body --silent --show-error --max-time 30 \2 -H "Authorization: Bearer $TENGU_API_KEY" \3 "https://firm.wealthnow.io/api/market/quote/AAPL"1import os, requests2 3r = requests.get(4 "https://firm.wealthnow.io/api/market/quote/AAPL",5 headers={"Authorization": f"Bearer {os.environ['TENGU_API_KEY']}"},6 timeout=30,7)8r.raise_for_status()9data = r.json() # {"ok": true, "ticker": "AAPL", "price": ...}10print(f"{data['ticker']} @ ${data['price']}")Inspect settlement headers on success: X-Credits-Settlement: consumed, plus cost / remaining when settlement is confirmed.
4. Higher-signal endpoints#
Congressional trades need Starter+ (2 credits). On Free you get 402 plan_required — that is an entitlement signal, not an auth failure:
1curl -H "Authorization: Bearer $TENGU_API_KEY" \2 "https://firm.wealthnow.io/api/v3/intel/congress"1r = requests.get(2 "https://firm.wealthnow.io/api/v3/intel/congress",3 headers={"Authorization": f"Bearer {os.environ['TENGU_API_KEY']}"},4 timeout=30,5)6r.raise_for_status()7data = r.json() # {"ok": true, "timestamp": ..., "items": [...], "count": N}8print(data["items"][:3])More routes to try ({ticker} → AAPL). Full catalog: API reference or GET /api/capabilities.
1# Live quote — market_data (1 credit)2curl -H "Authorization: Bearer $TENGU_API_KEY" \3 "https://firm.wealthnow.io/api/market/quote/AAPL"4 5# SEC filings — public_filings (1 credit)6curl -H "Authorization: Bearer $TENGU_API_KEY" \7 "https://firm.wealthnow.io/api/v3/fundamentals/sec_filings?ticker=AAPL&limit=10"8 9# 13F holdings — funds (4 credits, Starter+)10curl -H "Authorization: Bearer $TENGU_API_KEY" \11 "https://firm.wealthnow.io/api/v3/intel/sec13f?ticker=AAPL"12 13# Private markets search — 5 credits, Pro+ or add-on14curl -H "Authorization: Bearer $TENGU_API_KEY" \15 "https://firm.wealthnow.io/api/v3/private_markets/search?q=stripe"Plan gates live in Pricing & credits. Calling a locked product returns 402 plan_required with no credit debit — upgrade under Plans.
Production client#
These clients retry HTTP 429 and 502 responses, and a 503 only when it carries
Retry-After or "retryable": true. They preserve the API's error code, message, and
request ID, reading a route-level 503's status and reason when it has no error.
Successful response data stays endpoint-specific; validate its fields before using it.
1import os, time, requests2 3FIRM_BASE = "https://firm.wealthnow.io"4KEY = os.environ.get("TENGU_API_KEY", "")5if not KEY.startswith("tengu_"):6 raise SystemExit("Set TENGU_API_KEY to your Wealthnow key before making requests.")7SESSION = requests.Session()8SESSION.headers["Authorization"] = f"Bearer {KEY}"9 10class TenguError(RuntimeError):11 def __init__(self, status, code, message, request_id=None):12 suffix = f" (request_id={request_id})" if request_id else ""13 super().__init__(f"[{status} {code}] {message}{suffix}")14 self.status, self.code = status, code15 self.message, self.request_id = message, request_id16 17def _string_field(body, name):18 value = body.get(name)19 return value if isinstance(value, str) and value else None20 21def _parse_error(resp):22 """Read flat FIRM errors, route-level 503s, nested legacy errors, and Brain problem+json."""23 request_id = resp.headers.get("X-Request-ID")24 header_code = resp.headers.get("X-Error-Code")25 try:26 body = resp.json()27 except ValueError:28 return header_code or "unknown", (resp.text or resp.reason or "")[:200], request_id29 if not isinstance(body, dict):30 return header_code or "unknown", resp.reason or "", request_id31 request_id = _string_field(body, "request_id") or request_id32 detail = body.get("detail")33 nested = detail if isinstance(detail, dict) else {}34 # A route-level 503 has no `error`: its code is `status` and its message `reason`.35 code = (_string_field(nested, "error_code") or _string_field(body, "error_code")36 or _string_field(body, "error")37 or _string_field(body, "code") or header_code38 or _string_field(body, "status") or "unknown")39 message = (_string_field(nested, "error_message") or _string_field(body, "detail")40 or _string_field(body, "message") or _string_field(body, "reason")41 or resp.reason or "")42 return code, message, request_id43 44def _is_retryable(resp):45 if resp.status_code in (429, 502):46 return True47 if resp.status_code != 503:48 return False49 # Most 503s are final (no data exists); retry only the ones FIRM marks.50 if resp.headers.get("Retry-After") is not None:51 return True52 try:53 body = resp.json()54 except ValueError:55 return False56 return isinstance(body, dict) and body.get("retryable") is True57 58def firm_call(path, params=None, method="GET", json_body=None, max_retries=4):59 """Return JSON or raise TenguError. Retry 429, 502, and 503s marked retryable."""60 if max_retries < 1:61 raise ValueError("max_retries must be at least 1")62 for attempt in range(max_retries):63 resp = SESSION.request(method, FIRM_BASE + path, params=params,64 json=json_body, timeout=30)65 if resp.ok:66 return resp.json()67 if attempt < max_retries - 1 and _is_retryable(resp):68 default_wait = 60 if resp.status_code == 429 else 569 try:70 wait = max(0, int(resp.headers.get("Retry-After", default_wait)))71 except ValueError:72 wait = default_wait73 time.sleep(min(wait * (2 ** attempt), 120))74 continue75 code, message, request_id = _parse_error(resp)76 raise TenguError(resp.status_code, code, message, request_id)77 78# Market data is included on Free and paid plans; this call consumes one credit.79quote = firm_call("/api/market/quote/AAPL")80print(quote)1const FIRM_BASE = "https://firm.wealthnow.io";2const KEY = process.env.TENGU_API_KEY;3if (!KEY?.startsWith("tengu_")) {4 throw new Error("Set TENGU_API_KEY to your Wealthnow key before making requests.");5}6 7function isRecord(value: unknown): value is Record<string, unknown> {8 return typeof value === "object" && value !== null && !Array.isArray(value);9}10 11function stringField(body: Record<string, unknown>, name: string): string | undefined {12 const value = body[name];13 return typeof value === "string" && value.length > 0 ? value : undefined;14}15 16async function parseError(resp: Response): Promise<{17 code: string;18 message: string;19 requestId?: string;20}> {21 const text = await resp.text();22 let body: unknown;23 let fallbackMessage = resp.statusText;24 try {25 body = JSON.parse(text);26 } catch {27 fallbackMessage = text.slice(0, 200) || resp.statusText;28 }29 const record = isRecord(body) ? body : {};30 const nested = isRecord(record.detail) ? record.detail : {};31 // A route-level 503 has no `error`: its code is `status` and its message `reason`.32 return {33 code: stringField(nested, "error_code") ?? stringField(record, "error_code")34 ?? stringField(record, "error")35 ?? stringField(record, "code") ?? resp.headers.get("X-Error-Code")36 ?? stringField(record, "status") ?? "unknown",37 message: (stringField(nested, "error_message") ?? stringField(record, "detail")38 ?? stringField(record, "message") ?? stringField(record, "reason")) || fallbackMessage,39 requestId: stringField(record, "request_id")40 ?? resp.headers.get("X-Request-ID") ?? undefined,41 };42}43 44async function isRetryable(resp: Response): Promise<boolean> {45 if (resp.status === 429 || resp.status === 502) {46 return true;47 }48 if (resp.status !== 503) {49 return false;50 }51 // Most 503s are final (no data exists); retry only the ones FIRM marks.52 if (resp.headers.has("Retry-After")) {53 return true;54 }55 try {56 const body: unknown = await resp.clone().json();57 return isRecord(body) && body.retryable === true;58 } catch {59 return false;60 }61}62 63export async function firmCall(64 path: string,65 init: RequestInit = {},66 maxRetries = 4,67): Promise<unknown> {68 if (!Number.isInteger(maxRetries) || maxRetries < 1) {69 throw new Error("maxRetries must be a positive integer");70 }71 const headers = new Headers(init.headers);72 headers.set("Authorization", `Bearer ${KEY}`);73 for (let attempt = 0; attempt < maxRetries; attempt++) {74 const resp = await fetch(`${FIRM_BASE}${path}`, { ...init, headers });75 if (resp.ok) {76 const data: unknown = await resp.json();77 return data;78 }79 if (attempt < maxRetries - 1 && (await isRetryable(resp))) {80 const defaultWait = resp.status === 429 ? 60 : 5;81 const parsed = Number(resp.headers.get("Retry-After") ?? defaultWait);82 const wait = Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultWait;83 await new Promise((resolve) => setTimeout(resolve, Math.min(wait * 2 ** attempt, 120) * 1000));84 continue;85 }86 const { code, message, requestId } = await parseError(resp);87 throw new Error(`[${resp.status} ${code}] ${message}${requestId ? ` (request_id=${requestId})` : ""}`);88 }89 throw new Error("exhausted retries");90}The Brain agent API (Enterprise)#
Brain is an OpenAI/Anthropic-compatible agent that orchestrates FIRM tools for you. Enterprise only. Keys without Brain entitlement receive 403 CHAT_ENTITLEMENT_REQUIRED. On Pro and below, call FIRM REST directly.
1from openai import OpenAI2import os3 4client = OpenAI(api_key=os.environ["TENGU_API_KEY"], base_url="https://brain.wealthnow.io/v1")5 6resp = client.chat.completions.create(7 model="brain",8 messages=[{9 "role": "user",10 "content": "What did Nancy Pelosi trade recently, and how did NVDA move around those dates?",11 }],12)13print(resp.choices[0].message.content)Brain is stateless per request (send full messages[] to continue), supports stream=true, and also exposes Anthropic's shape at POST /v1/messages. It meters the shared wallet by token.
Discover endpoints (no key)#
1curl --fail-with-body --silent --show-error --max-time 30 \2 "https://firm.wealthnow.io/api/capabilities" \3 | jq '{manifest_version, tool_count, groups}'Errors#
FIRM uses a flat error envelope: error is the machine-readable code and detail
is the message string (message with ?shape=firm). Brain uses code and detail in problem+json:
1{2 "ok": false,3 "error": "unauthorized",4 "detail": "invalid or missing API key — send your key as 'Authorization: Bearer <key>' ...",5 "request_id": "example-request-id"6}The same code is usually also on the X-Error-Code response header, and request_id on X-Request-ID —
quote the request_id when you contact support and the exact request can be pulled from the logs.
Do not write body["detail"]["error_code"]. detail is a string here, so indexing into it
raises TypeError/AttributeError and turns a clean 401 into a stack trace. Use the _parse_error
helper in Production client, which handles this envelope, ?shape=firm, and Brain's problem+json alike.
Some 503 responses come from the route itself and carry status instead of error, with no
X-Error-Code header. Read status as the code and reason as the message, and retry only when
retryable is true. The production clients do this:
1{2 "ok": false,3 "available": false,4 "status": "unavailable_symbol_volume_curve",5 "reason": "capture_source_unavailable",6 "billable": false,7 "retryable": true8}Every non-2xx response is non-billable. A request refused before its credit reservation is never
debited; one that fails after it is refunded (X-Credits-Settlement: refunded, X-Credits-Cost: 0).
| Status | error | Meaning | What to do |
|---|---|---|---|
401 | unauthorized | Key missing, malformed, or not sent at all. | See Troubleshoot a 401 — an unset $TENGU_API_KEY looks identical to a bad key. |
402 | plan_required | Product not in your plan. detail names the product and the plan that unlocks it. | Upgrade. Don't retry. |
402 | usage_exceeded | Wallet empty / overage cap reached. | Top up or upgrade. Don't retry. |
403 | account_suspended | Billing lapsed. | Resolve billing in the dashboard. |
404 | not_found | Unknown route or symbol. | Check the path against GET /api/capabilities. |
422 | validation | A parameter failed validation; detail names it. Some routes send a more specific code. | Fix the request. Don't retry it unchanged. |
429 | rate_limited | Plan rate limit exceeded. | Wait Retry-After seconds, then retry with backoff. |
429 | billing_unavailable | The metering call didn't confirm a debit, so the request was refused. | Wait Retry-After seconds (5), then retry. |
500 | internal_error | Unexpected server error. | Retry a few times with backoff; report the request_id if it persists. |
502 | unkey_unreachable | Key service blip (transient). | Retry after Retry-After seconds (5). |
502 | billing_unavailable | Credit reservation unavailable (transient). | Retry after Retry-After seconds (5). |
502 | invalid_paid_response | The result wasn't complete JSON within the response size limit. | Retry; if it repeats, narrow the request (smaller limit or date range). |
503 | paid_data_unavailable | The route couldn't produce a complete, usable result. | Don't retry in a loop; try later or with different parameters. |
503 | capability_data_unavailable | The route is tagged data_pending_v1 and serves no data (retryable: false, status is unavailable_data_pending). | Don't retry. |
503 | capability_policy_unavailable | Serving policy temporarily unavailable (retryable: true). | Retry after a few seconds with backoff. |
503 | billing_policy_unavailable | Billing policy temporarily unavailable. | Retry after Retry-After seconds (5). |
503 | status field, no error | A route-level unavailable body (see above). | Retry only when retryable is true. |
504 | request_timeout | The request exceeded the server's time budget. | Narrow the request (add limit, or a start and end date) before retrying. |
Troubleshoot a 401#
A 401 can mean the key is missing, malformed, invalid, expired, or disabled. Check how the request sends the credential before replacing the key.
-
Check that the environment variable is set without printing it:
: "${TENGU_API_KEY:?Set TENGU_API_KEY before making a request}" -
Call
https://firm.wealthnow.iodirectly. Redirects can change which credentials reach the destination, depending on the client and redirect target. -
Send
Authorization: BearerorX-API-Key. For example,-H "Authorization: Bearer $TENGU_API_KEY"quotes the whole shell argument; quotation marks around the token itself become part of the credential. -
Check that the key is enabled and unexpired in Wealthnow API → Overview. Rotate any exposed key even if it still works.
Check a documented endpoint#
Market data is included on every plan. This request consumes one credit on success; it tests authentication without requiring a higher-tier product.
1: "${TENGU_API_KEY:?Set TENGU_API_KEY before making a request}"2curl -sS -o /dev/null -w '%{http_code}\n' \3 -H "Authorization: Bearer $TENGU_API_KEY" \4 "https://firm.wealthnow.io/api/market/quote/AAPL"| Status | Next step |
|---|---|
200 | The key worked for this request. Compare the path and headers with the failing request. |
401 | Check the credential, its delivery, and its enabled/expiry state. |
402 | Read the error code and message for a wallet or entitlement restriction. |
429 | Wait for Retry-After, then retry within the plan's rate limit. |
502 | Retry after the reported delay; the key service may be unavailable. |
Distinguish a quote from a paid snapshot#
The quote and snapshot endpoints have different product entitlements:
| Endpoint | Product | Minimum plan | Credits on success |
|---|---|---|---|
/api/market/quote/AAPL | market_data | Free | 1 |
/api/snapshot/AAPL | quant_signals | Starter | 3 |
A funded Free key can return 200 for the quote and 402 plan_required for the
snapshot. That 402 concerns product access, not an invalid key. Read the error
code and message before changing plans or replacing a credential.
Use GET /api/capabilities to discover routes before calling them. A 401 on a
guessed path does not establish that the key is invalid. Your plan and balance
are visible in Wealthnow API → Overview; /api/me, /api/usage, and
/api/keys/whoami are not documented introspection endpoints. Include the response request ID
when contacting support; never include the key.
For Claude Code & AI agents#
If you're an agent setting this up for a user, this is all you need:
- Config:
TENGU_API_KEYfrom env (never hardcode). FIRM basehttps://firm.wealthnow.io; Brain basehttps://brain.wealthnow.io/v1. Auth:Authorization: Bearer $TENGU_API_KEYon both. Assert the variable is non-empty before your first call — an unset var sends an empty Bearer token and produces a 401 that is byte-identical to an invalid key. - Discovery:
GET https://firm.wealthnow.io/api/capabilities(no key needed) lists the live versioned data catalog with params — read it before generating calls instead of guessing paths. (A few legacy convenience routes like/api/market/quote/{ticker}shown above aren't in the manifest but remain supported.) - Responses: response fields differ per endpoint: equity quotes return
tickerandpriceat the top level, crypto quotes usequote, and feeds/search may useitemsorresults. Read each endpoint's example before accessing its fields. - Error parsing: the error envelope is flat —
{"ok": false, "error": "<code>", "detail": "<message>", "request_id": ...}.detailis a string. Readingbody["detail"]["error_code"]raises and will make a routine 401 look like a broken API. A route-level503hasstatusandreasoninstead oferroranddetail. Use the production client's_parse_error. - 401 diagnosis: check credential delivery and enabled/expiry state, then use the one-credit check in Check a documented endpoint. Include the request ID in support reports; never include a key.
- Retry policy: retry
429and502(waitRetry-After), and a503only when it carriesRetry-Afteror"retryable": true, with exponential backoff capped at 120s. Narrow a504request before retrying it. Do not retry401/402/403/404/422or other503s — surface them to the user (they need a key, an upgrade, a fixed path, or data that is not available). - Budget: each FIRM call costs a fixed number of credits by product (1–10; see Pricing & credits).
/api/capabilitiesis free. Private Markets search costs 5 credits and requires Pro+ or its add-on; listing/api/v2/datasetscosts 4 credits underfundsand requires Starter+. Brain is metered per token, priced at 2,500 credits per $1 of model cost. A402 plan_requiredresponse describes the entitlement restriction indetail. - Ticker collisions — never present equity data as crypto. Nine symbols are BOTH a crypto asset and a US-listed equity:
BTC,ETH,LINK,LTC,COMP,ARB,NEAR,APT,ATOM. On/api/v1/copilot/score/{ticker}and/api/v3/intel/ml_prediction/{ticker}, passasset_class=equityorasset_class=cryptoexplicitly. A response for one of those nine carries aticker_collisionnote — surface it to the user, don't strip it. See Ticker collisions below. - Use the production client verbatim — it already encodes the envelope, error shapes, and backoff correctly.
Ticker collisions: crypto vs equity#
Nine tickers are ambiguous — they name both a crypto asset and a US-listed equity:
BTC · ETH · LINK · LTC · COMP · ARB · NEAR · APT · ATOM
Scoring and prediction endpoints take an explicit asset_class parameter so the namespace is never guessed:
1# The US-listed EQUITY named BTC (not Bitcoin)2curl -H "X-API-Key: $TENGU_API_KEY" \3 "https://firm.wealthnow.io/api/v1/copilot/score/BTC?asset_class=equity"The response carries an explicit disambiguation note:
1{2 "ticker_collision": {3 "note": "This is the US-listed equity 'BTC'. For the crypto asset, pass asset_class=crypto.",4 "crypto_available": false,5 "asset_class": "equity"6 }7}Asking for the crypto asset fails closed rather than silently returning the equity:
1curl -H "X-API-Key: $TENGU_API_KEY" \2 "https://firm.wealthnow.io/api/v1/copilot/score/BTC?asset_class=crypto"3# 404 {"error": "crypto_model_unavailable", ...}A 404 crypto_model_unavailable means the crypto model does not cover that asset — it does not mean "use the equity instead." Non-colliding tickers like AAPL are unaffected and carry no collision note.
For agents: treat ticker_collision as required context to relay. Presenting the equity BTC as Bitcoin is a correctness failure, not a formatting detail.
Next steps#
| Go deeper | |
|---|---|
| API reference | Every endpoint, plan access, credit costs, and error shapes |
| Pricing & credits | Tiers, add-ons, and per-call costs |
| Connect via MCP & SDKs | Claude, Cursor, and generated clients |
| Chat parity | Make Brain feel like Tengu Chat with optional context |