The Data feed (API): developer guide
The analyst research on your company as JSON, for you to show in your own design. This is for the developer building it: about a day’s work, most of it the layout.
In beta. The API is live and in use, and we may add fields as it grows. If anything here does not match what you see, tell us.
How it fits together
- You make an RSA key pair and send us the public half. The private half never leaves your server.
- We send you a Client Token and your feed’s URL.
- For each request, your server signs a short token with the private key and calls the URL with it.
- We check the signature against your public key and return the research as JSON, which you lay out in your own design.
Everything happens server to server. Never call the feed from a browser: the page would have to carry your private key, and anyone could read it.
1. Make a key pair
Two OpenSSL commands. The first writes the private key in PKCS#8 form, which every example below reads; older
genrsa commands can write a form some libraries refuse.
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out company-private.pem
openssl pkey -in company-private.pem -pubout -out company-public.pem
Send us company-public.pem, or paste it into the sign-up form. Keep
company-private.pem in your server’s secret store and nowhere else. If a private key is
ever sent to anyone, make a new pair.
2. Sign a token
A JSON Web Token, signed with your private key, carrying these:
| Claim | What it means |
|---|---|
| alg (header) | RS256. Any other algorithm is refused. |
| iss | Your Client Token, exactly as we sent it. |
| aud | ResearchTree |
| iat, exp | Issued-at and expiry. Keep tokens short-lived: 30 minutes is plenty. |
3. Call the feed
Send the token as Authorization: Bearer <token> in a GET to your feed’s URL. It
has this shape; use the one we send you exactly as given.
GET https://research-tree-api.com/api/partners/erf/companyfeed/{partnerTag} Every parameter is optional:
| Parameter | What it means |
|---|---|
| includeResearch | Analyst research. Default true. |
| includeMedia | Video and audio. Default true. |
| includeBlog | Commentary and blogs. Default true. |
| includeEvent | Company events. Default false, and only returned if events are switched on for you. |
| includeSingleCompanyNote | Single-company research notes. |
| includeSectorNote | Sector notes. |
| includeMorningNote | Morning notes. |
| startRow, pageLength | Paging. pageLength is at most 100. |
| Do not send it. Visitors to an IR page are anonymous; the feed works without it. | |
| filterByUser | Leave it out, for the same reason. |
Examples
Each one reads the Client Token and the feed URL from environment variables, signs a 30-minute token and returns the items.
// npm install jsonwebtoken
import { readFileSync } from 'node:fs';
import jwt from 'jsonwebtoken';
const privateKey = readFileSync('company-private.pem', 'utf8');
export async function fetchResearchFeed() {
const token = jwt.sign({}, privateKey, {
algorithm: 'RS256',
issuer: process.env.RT_CLIENT_TOKEN,
audience: 'ResearchTree',
expiresIn: '30m',
});
const url = new URL(process.env.RT_FEED_URL); // exactly as we sent it
url.searchParams.set('pageLength', '20');
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const feed = await response.json();
if (feed.Message) throw new Error(feed.Message); // errors arrive as HTTP 200
return feed.Items;
} # pip install "pyjwt[crypto]" requests
import os
import time
import jwt
import requests
with open("company-private.pem") as handle:
PRIVATE_KEY = handle.read()
def fetch_research_feed() -> list[dict]:
now = int(time.time())
claims = {"iss": os.environ["RT_CLIENT_TOKEN"], "aud": "ResearchTree", "iat": now, "exp": now + 1800}
token = jwt.encode(claims, PRIVATE_KEY, algorithm="RS256")
response = requests.get(
os.environ["RT_FEED_URL"], # exactly as we sent it
params={"pageLength": 20},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
feed = response.json()
if feed.get("Message"): # errors arrive as HTTP 200
raise RuntimeError(feed["Message"])
return feed["Items"] // supabase/functions/research-feed/index.ts
import { importPKCS8, SignJWT } from 'npm:jose@5';
// Secrets set with `supabase secrets set`. A PEM pasted into one secret keeps
// its line breaks as \n, so put them back before reading the key.
const PRIVATE_KEY = Deno.env.get('RT_PRIVATE_KEY')!.replace(/\\n/g, '\n');
const CLIENT_TOKEN = Deno.env.get('RT_CLIENT_TOKEN')!;
const FEED_URL = Deno.env.get('RT_FEED_URL')!; // exactly as we sent it
const CORS = { 'Access-Control-Allow-Origin': 'https://www.yourcompany.com' };
Deno.serve(async (request) => {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: { ...CORS, 'Access-Control-Allow-Headers': 'authorization, content-type' } });
}
const token = await new SignJWT({})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(CLIENT_TOKEN)
.setAudience('ResearchTree')
.setIssuedAt()
.setExpirationTime('30m')
.sign(await importPKCS8(PRIVATE_KEY, 'RS256'));
const url = new URL(FEED_URL);
url.searchParams.set('pageLength', '20');
const feed = await (await fetch(url, { headers: { Authorization: `Bearer ${token}` } })).json();
if (feed.Message) {
return Response.json({ error: feed.Message }, { status: 502, headers: CORS });
}
return Response.json(feed.Items, { headers: { ...CORS, 'Cache-Control': 'max-age=300' } });
}); What comes back
A JSON object. Check Message first; if it is empty, the notes are in
Items, each with these fields:
| Field | What it means |
|---|---|
| ContentId | Research Tree’s id for the item. |
| Headline | The title. Events are prefixed "EVENT: ". |
| Synopsis | A short summary. Empty for media. |
| ProviderName | The broker or research house. |
| ProviderLogoUrl | Their logo. Can be null: fall back to ProviderName. |
| ThumbnailUrl | An image for the item. |
| ContentType | Research, Media, Blog or Event. |
| Companies | Id, CompanyName, Ticker, Exchange for each company covered. Any field can be null and the list can be empty. |
| PublishedDate, LastModifiedDateTime | When it was published and last changed. |
| NumOfPages | Page count, for research. |
| IsPremiumOnly, OnlyAvailableToPayingClients | Whether reading it needs a paid Research Tree account. |
| LoggedInUserHasAccess | Whether the reader can open it. Your visitors are not logged in, so this is the answer for an anonymous reader. |
| ResearchTreeUrl | The link to the item. Use it exactly as returned: its ending credits your company and routes the reader correctly, and a rebuilt URL returns a 404. |
| DirectDownloadViewerUrl, PartnerViewerUrl | For a viewer page only. Null unless one is set up for you. |
When something goes wrong
Errors come back as HTTP 200 with Message set, so a status check alone will not
catch them.
| Message | What to do |
|---|---|
| Unable to extract issuer from token | The iss claim is missing. Set it to your Client Token. |
| Company not found | The Client Token or the URL is not one we issued. Use both exactly as given. |
| Invalid token signature | The token was not signed with the private key matching the public key we hold. Check the key, or send us the public key again. |
| Token has expired | Sign a fresh token. Check the server clock if this happens with new tokens. |
| Invalid token audience | aud must be exactly ResearchTree. |
| Invalid token algorithm | Sign with RS256. |
| API not enabled for this company | The feed is not switched on for you yet. Tell us and we will check. |
Caching
We cache each feed for three minutes, so calling more often than that returns the same notes. Cache on your side too, for five minutes or so, rather than calling the feed on every page view.
Stuck on the Data feed?
Send us the Message you are getting and we will look at it with you.