Blog / Guides

[14 / 15]

Sep '255 min read

An empty terminal is the feed working

A key, one socket, and about twelve lines in Python or TypeScript. Plus the four places people stall on the way, including the one where nothing is wrong and nothing arrives.

Josh Perrera Developer Advocate

Four people this month have pasted some version of the same line into the community Slack: connected, subscribed, eleven minutes, nothing. All four had working code. The gateway only puts an item on your socket once the Verdict Engine has finished scoring it, so a quiet console at 04:30 UTC means the wire is quiet, not that you're broken. This is the whole path from an empty directory to a scored release printing in your terminal, with the stalls marked as you reach them.

One key, shown once

Sign in, open API keys in the dashboard, click Create key and name it something you'll still recognise in six months. The placeholder in that dialog says prod-quant-bot, and naming keys after the process that holds them is the reason you'll be able to revoke the right one later.

What comes back is fc_live_ followed by 32 random characters. We store a SHA-256 of the whole string plus the first twelve characters, which is why the dashboard can show you fc_live_a1b2 forever and the secret exactly once. Copy it out of the dialog before you close it. There is no reveal-again path, only create a new key and revoke the old one.

How many you can hold at once comes off your plan: one on Free, three on Starter, ten on Pro, uncapped on Quant. The internal key that powers the dashboard doesn't consume a slot.

bash
export FORECITE_API_KEY=fc_live_...

Call /v1/me before you call anything else

bash
curl https://api.forecite.dev/v1/me \  -H "Authorization: Bearer $FORECITE_API_KEY"

Two reasons this is the first request you make. It's metadata-only and doesn't count against any quota, so a typo here costs you nothing. And it answers the questions that decide everything below:

json
{ "data": { "key_id": "…", "name": "prod-quant-bot", "tier": "starter",  "limits": { "realtime_feeds_per_day": 5000, "historical_api": true,    "historical_lookback_hours": 48, "max_page_size": 50,    "artifact_scoring_per_day": false, "warren": true } } }

A wrong or revoked key gives you a 401 with {"error":{"code":"invalid_api_key"}}; a missing or malformed Authorization header gives missing_api_key at the same status. Those are the only two shapes, and neither one is a propagation delay, so retrying an invalid_api_key won't fix it.

If tier came back free, note it now rather than in twenty minutes: GET /v1/feeds will refuse with a 403 and historical_api_unavailable. REST history starts on Starter. Free is the realtime socket and nothing else, capped at 50 deliveries a day.

Twelve lines and a socket

Python first. The package is forecite on PyPI, currently 0.1.3, and it pulls in requests and websocket-client.

bash
pip install forecite
python
import osfrom forecite import Forecite
forecite = Forecite(os.environ["FORECITE_API_KEY"])
def on_feed(item):    s = item["scoring"]    print(s["actionability_score"], "|", item["title"],          "| short", s["sentiment_short"], "| conviction", s["sentiment_conviction"])
stream = forecite.stream(    filters={"actionability": True},    snapshot=5,    on_feed=on_feed,    on_welcome=lambda w: print("connected:", w["tier"], w["limits"]),    on_error=print,)stream.run()   # blocks; stream.run_in_thread() if you'd rather it didn't

TypeScript is the same shape with the handlers in a second argument:

ts
import { Forecite } from "@forecite/sdk";
const forecite = new Forecite(process.env.FORECITE_API_KEY!);
forecite.stream(  { filters: { actionability: true }, snapshot: 5, reconnect: false },  {    onWelcome: (w) => console.log("connected:", w.tier),    onFeed: (item, { snapshot }) =>      console.log(snapshot ? "[snap]" : "[live]", item.scoring?.actionability_score, item.title),    onError: (e) => console.error(e),  },);

@forecite/sdk runs on Node 18, but streaming needs a global WebSocket, which means Node 22 or a browser. On 18 or 20 you don't get a connection failure, you get onError with the string No global WebSocket available, which reads like noise at 11pm and goes straight past you. That's stall number one.

I set reconnect: false above on purpose, and I'd leave it off while you're developing. The default is on, with a two-second backoff and no ceiling, so a revoked key gives you a 401 loop that will run politely all night. The Python client doesn't reconnect at all: run() returns and your process exits, which is louder and, for a first run, better.

Which frame you didn't get

  1. 1HTTP upgrade with ?api_key= or a Bearer headerauth happens here, before the socket opens
  2. 2server → welcometier + limits
  3. 3client → subscribefilters, snapshot
  4. 4server → subscribedechoes the normalised filter
  5. 5server → feed, snapshot: trueup to 50, oldest first
  6. 6server → feed, snapshot: falselive, when scoring finishes
Six frames in order. Whichever one you never saw is the thing to go and fix.connection handshake, in order

No welcome at all means you never got past the upgrade. The gateway authenticates before it accepts the socket and writes back a bare HTTP 401 with {"error":"invalid_credential"} or {"error":"missing_credential"} in the body, so there's no error frame to catch, only a handshake that failed.

The subscribed frame is the useful one to log, because it echoes back the filter after normalisation rather than the filter you sent. Unknown keys are dropped silently, and {"symbols": "TSLA"} (a string where an array belongs) normalises to an empty filter that matches everything. Seeing {"filters":{}} come back when you expected a ticker is a two-second diagnosis and otherwise a long afternoon.

snapshot: 5 is the difference between debugging and waiting

The default is snapshot: 0. Our own dashboard subscribes that way because it's a live window that forgets, and if you copy it for your first run you inherit the empty terminal from the top of this article. Ask for five and the server replays the most recent matching scored items oldest-first, so the pipe proves itself in under a second and the live frames that follow read as a continuation.

The server caps the request at 50 no matter what you send. Before you reach for that ceiling: replayed frames are metered exactly like live ones, and every feed frame carries a count field with your authoritative running daily total. On a Free key that's 50 deliveries in the day, total, so one snapshot: 50 and a single reconnect and you're finished until midnight UTC.

Every score is null and your key is fine

If your printed line comes out with None in each numeric slot, look at scoring.locked before you look at anything else. Free keys stream normally and get the article, the source and the symbols, then a scoring block where every field is null and message holds an upgrade prompt. The real values never leave the server. Scored feed starts on Starter at $49 a month, or $39 if you pay annually.

With a Starter key, one line of the output looks like this:

78 | Q3 EPS $4.93 vs $4.59 est; FY guide raised | short 3 | conviction 66

actionability_score is 0 to 100, compounded from five sub-scores with novelty carrying the largest weight at 0.30, and the gate for actionable sits at 60. sentiment_short and sentiment_long are signed, −5 to +5, and are allowed to disagree. sentiment_conviction is 0 to 100 and is a separate question from direction. There's also a sentiment_score on the block, 0 to 10 with 5 as neutral; that's the legacy column, it's what the REST sentiment_min parameter filters on, and it isn't what you want in new code.

You now have a running subscription, five replayed items and a live one, with sub-scores attached. Two things to do before you leave it running. Move your universe into filters so the match happens on our side instead of in your on_feed, and know what that costs you: a symbol filter compares against the tickers attached to the row, so a macro print with no symbol linked to it never matches, and you will not see it drop. Then dedupe on id. There's no replay by sequence number and no per-connection cursor, so anything scored while you were disconnected is never delivered, and the fix in both directions is the same four lines: keep a set of ids, re-request a snapshot on every reconnect, ignore what you've already seen.