Developers
Building with HitSonar?
Bring HitSonar’s YouTube research signals into your own application. The API returns the same analysis the product shows: one versioned HitSonar Score with its full breakdown, search demand as a band, the first page of results, and the queries people actually type. Nothing here is a preview; every response on this page is real.
Quickstart
- Create a key. Sign in at the console, name your organization if you have not, and choose Create API Key. It is shown once; store it as a secret, on a server.
- Make a call. Send it as a bearer token. This one costs nothing: autocomplete only.
curl -H "Authorization: Bearer $HITSONAR_API_KEY" \ "https://app.hitsonar.com/v2/suggest?q=how+to+start+a+podcast" - Read the score. A full analysis (
POST /v2/analyze) returnsscore.overallandscore.grade: the one opportunity number, and its letter.
Where a secret key belongs
An hs_ key is a secret. Keep it on a server.
Do not put a HitSonar API key in browser JavaScript, a React or Vue frontend bundle, a public mobile app, a public GitHub repository, HTML, or a public config file. Anything shipped to a browser or an app store can be read by anyone who opens it, however it is minified or obfuscated. The right shape is your own backend between the two:
This is a different concern from CORS, even though both are about the browser. CORS (below) controls whichorigins a browser will let a request reach; it says nothing about who should hold the credential making that request. HitSonar allows cross-origin calls so the try-it panel on this page, and a server you run, can reach the API directly — that does not make it safe to send a secret key from code a stranger’s browser runs. The one exception on this page is the try-it panel itself, for testing your own key by hand; it is not a pattern to copy into a real application.
Authentication
Every call except /v2/health needs a key, as a bearer token:
Authorization: Bearer hs_...Keys start with hs_ and belong to an organization, not a person, so a key keeps working when someone leaves. An owner or admin creates and revokes them in the console. A key is shown once, at creation, and is stored hashed after that: HitSonar itself cannot show it to you again.
Every /v2/* response (and every /v1/* one) carries CORS headers, so a server-side call, a browser call from your own domain, and the try-it panel on this page all work the same way. See “Where a secret key belongs” above before deciding where that call should come from.
Try it live
A real request to the real API, sent from this page with a key you paste in. Nothing is saved.
/v2/analyze
The full analysis: autocomplete, Trends, the first page of results, and the score built from them. A cold run takes about 20 seconds; a repeat of the same phrase within a day is served from cache in milliseconds and is free.
Parameters
| Field | Type | Meaning |
|---|---|---|
phrase | string, required | 2 to 80 characters. Lower-cased and single-spaced before use. |
geo | string, optional | "US" is the only value today. Default "US". |
fresh | boolean, optional | true runs it again instead of serving a cached result. Costs one analysis. |
Request
curl -X POST https://app.hitsonar.com/v2/analyze \
-H "Authorization: Bearer $HITSONAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phrase": "how to start a podcast", "geo": "US" }'Response headers
| Field | Type | Meaning |
|---|---|---|
X-Request-Id | string | Also in every error body. Quote it to support: "request req_... failed". |
X-RateLimit-Limit, -Remaining, -Reset | integer | This key's per-minute rate limit. Sent for organization keys where a rate-limit state exists -- not for operator-configured keys, and not on a 401, which has no identified key. |
X-Usage-Limit, -Remaining, -Reset | integer | This organization's monthly analyses. Only on /v2/analyze, and only where an organization allowance was consulted. |
The fields worth mapping
| Field | Type | Meaning |
|---|---|---|
score.overall, score.grade | 0-100, A-F | The one opportunity score. Show it; never compute your own. |
score.version | string | The scoring model (SCORING_VERSION). Store it beside any score you keep -- versions are not comparable. |
ranking.serpOpening | 0-100 or null | Describes this one results page. Not an opportunity score. |
ranking.titleShapes, ranking.videos[].title | string[] | For a title prompt: the need-phrases and titles already working. |
suggestions[].phrase, seedSuggestions | string[], 0-14 or null | For a tag builder: real autocomplete queries, and how many YouTube offers for the bare phrase. |
score.searchBand.label | string or null | A tenfold band such as "10K to 100K" -- lead with this over the raw range. |
analysisStatus | complete | degraded | provisional | Mark a provisional score as provisional on screen: it read no first page. |
trend, ranking, *.subscribers | nullable | null means unknown, never 0. A hidden subscriber count is null. |
Full field-by-field reference, with every value: the OpenAPI schema.
/v2/suggest
Autocomplete only. No YouTube quota, no monthly allowance, a few seconds. Use it for tag ideas, or to check whether a phrase is worth a full analysis.
| Field | Type | Meaning |
|---|---|---|
q | string, required | The phrase to expand. |
geo | string, optional | "US" is the only value today. |
curl -H "Authorization: Bearer $HITSONAR_API_KEY" \
"https://app.hitsonar.com/v2/suggest?q=how+to+start+a+podcast"Same request/response shape in curl, JavaScript, TypeScript, Python, Ruby, PHP, C#, Java and Go: see POST /v2/analyze above for the full language matrix. Swap the method and path.
/v2/titles
A video title in, a better one out — with the research behind it. You send a title and nothing else: no keyword, no search volume, no competition figure, no brief. HitSonar works out what the video is about, researches the topics people actually search, rejects the search angles whose results page belongs to a different kind of video, reads how the titles ranking there are built, scores the title you sent, and writes and scores alternatives.
Parameters
| Field | Type | Meaning |
|---|---|---|
title | string, required | 2 to 120 characters. Trimmed and whitespace-collapsed; otherwise kept exactly as sent, because it is scored and echoed back. |
description | string, optional | What actually happens in the video, up to 800 characters. With it, nothing about the video's contents has to be inferred. |
targetViewer, format, angle, tone | string, optional | Free text that steers the writing. |
direction | string, optional | emotional | curiosity | search | shorter | direct. Steers the whole set. |
count | integer, optional | How many titles to return, 1 to 10 (default 10). Trims the set rather than shrinking the generation: the model is asked for the same 5-10 either way and the deterministic score still picks recommended from all of them, so a smaller count gives the same top pick with fewer alternatives, at the same cost. |
researchId | string, optional | Start from an analysis your organization already has instead of deriving topics. An id from another organization answers exactly as a non-existent one does. |
challengeTopic | boolean, optional | Only meaningful with researchId. Measures other topics too and lets them compete with it. Can cost analyses. |
geo | string, optional | "US" is the only value today. |
Request
curl -X POST https://app.hitsonar.com/v2/titles \
-H "Authorization: Bearer $HITSONAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "No Condemnation - Putting Down the Shame You Were Never Meant to Carry" }'Allow 120 seconds: a request that has to measure three topics it has never seen does real work. It costs one title generation from a separate monthly allowance, plus up to three new analyses — research.analysesRun says how many it actually spent, and anything your organization analyzed in the last 30 days is reused for free.
Two scores, and they are not the same number
| Field | Type | Meaning |
|---|---|---|
research.selected.opportunityScore | 0-100 · HitSonar Score | Is this TOPIC worth pursuing? The same score.overall POST /v2/analyze reports, from the same model, versioned by score.version. |
titleScore.overall | 0-100 · Title Opportunity | How well is this TITLE built against that evidence? Versioned separately, by titleScoreVersion. |
Never render both as score. Neither is a prediction — neither says a video will rank, or how many views or clicks it will get. Neither is produced by a language model either: both are computed, so the same input always gives the same number. The model proposes candidate topics and writes the titles; the deterministic title score decides which one is recommended.
The six dimensions, and the trade they expose
| Field | Type | Meaning |
|---|---|---|
coverage | 0-20 | Does the title contain the researched search phrase. |
alignment | 0-15 | Is it built like the titles already ranking on that page. |
differentiation | 0-15 | Does it stand apart from them. |
clickAppeal | 0-30 | Textual signals associated with viewer pull: relevance, information gaps, stakes, specificity, contrast, comprehension. A deterministic title-text heuristic, NOT a CTR prediction. |
clarity | 0-10 | Can a viewer tell what it means, without obvious empty bait. |
length | 0-10 | Does it fit a title bar. |
Read them as two halves. coverage + alignment (35) is whether the video can be found; clickAppeal + differentiation + clarity (55) is whether it is worth choosing. A rewrite that raises one half and lowers the other is making a trade, and the breakdown is the only place a caller can see which — do not show the total on its own.
Appeal outweighs findability deliberately, and that is what moved in titleScoreVersion 3.0.0; 2.0.0 had it even at 45 and 45. Inserting the researched phrase is the half a rewrite can always improve mechanically, and the reason somebody clicks is the half it deletes by accident. Read weight from the response rather than these numbers.
appealFloor is null on nearly every title. When it is not, a weakclickAppeal has held overall below what the dimensions add up to — capped at 69 below 10 of 30, at 79 below 15, uncapped from 15 up. It is the one rule here that is not a weighted sum, and it exists because 30 points still leaves 70: enough for a title that is easy to find and gives a viewer no reason to choose it to read as excellent. Before 3.0.0 overall was always the sum of dimensions; it is not any more, so show overall and explain it with appealFloor.note rather than re-adding the breakdown.
clickAppeal scores textual signals associated with viewer pull, including relevance, information gaps, stakes, specificity, contrast, and comprehension. It is a deterministic title-text heuristic, not a measurement of viewer psychology and not a CTR prediction, so do not convert it into a percentage or a rate. Capitalisation and punctuation cannot move it: a colon, an exclamation mark and ALL CAPS all score identically, by construction.
Since 4.0.0 a token never scores on its own. A word nominates a candidate structure, which earns nothing until it composes — does it have a subject and an object, does it relate to the researched context, does it form a meaningful proposition. Why I Stopped Putting My Emergency Fund in Index Funds scores a strong information gap; Why Morning Routine contains the same word, proposes nothing that could have a cause, and scores zero for curiosity. Direct utility is a complete route: a plain How to install X in Y opens no loop and still scores well. And it is relevance-gated — a well-built title about a different subject than the one researched is held near zero however well it reads.
The fields worth mapping
| Field | Type | Meaning |
|---|---|---|
research.status | selected | no_suitable_topic | no_suitable_topic means every candidate was rejected on search intent or had no analysis. Titles still come back, from the brief alone -- do not present them as search-backed. |
research.quality | complete | degraded | provisional | none | A recommendation resting on "none" must not look like one resting on "complete". |
research.considered[] | array | Every finalist and what happened to it. The rejections are the useful part: which angles HitSonar looked at and would not recommend, with the count behind it. |
research.selected.intentFit | strong | acceptable | mismatch | Whether the first page is the same kind of video. Three words, not a fourth score, and a mismatch always quotes its evidence in intentReason. |
titleStyle | object or null | Counted from the selected page's real titles, never produced by a model. Prevalence only: punctuation does nothing to a ranking. Null when no first page was read. |
context | object or null | A reading of your title's words, used to decide what to measure. Hypotheses, not evidence. No number in it, and nothing about what the video contains. |
brief.contentSupplied | boolean | false when you sent no description, so the brief was inferred from the title. Nothing about the video's contents was invented either way. |
usage.generations | object or null | The GENERATION allowance, not the analysis one. The X-Usage-* headers describe analyses and are not sent by this endpoint. |
What it will not do
It will not invent a subject: "Episode 11", "Sunday Message" and "My New Video" name an occasion rather than anything researchable, and get 422 insufficient_context instead of a guess. It will not invent what is in the video — given a title alone it writes about the subject the title states, and adds no lists, statistics, scripture references, names or results that were not supplied to it. It will not copy a competitor: it borrows structure and never wording. And it will not pick a topic on score alone — a phrase with more demand whose first page is worship music loses to a phrase with less demand whose first page is teaching.
Full field-by-field reference: the OpenAPI schema.
/v2/titles/score
Score titles you already have against research you already paid for. Everything a title score needs was bought by the analysis behind researchId, so scoring another candidate against it is arithmetic — and this endpoint costs nothing: no credits, no analyses, no title generations, and no YouTube, autocomplete, Trends or model request. The per-key rate limit still applies.
Use this instead of calling /v2/titles once per title. Six candidates that way would be six requests and thirty credits — and, worse than the cost, six DIFFERENT pieces of research, so the six scores would not be comparable with one another. One request here scores them all against one piece of evidence.
curl -X POST https://app.hitsonar.com/v2/titles/score \
-H "Authorization: Bearer $HITSONAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"researchId": "res_8Kd2mQ1pR9vNc4w",
"titles": [
"Overcoming Shame: How to Stop Carrying What You Were Forgiven For",
"No Condemnation — Putting Down the Shame You Were Never Meant to Carry",
"Why I Stopped Carrying Shame I Was Never Meant to Carry"
]
}'researchId comes from research.selected.researchId on a POST /v2/titles response, or from an analysis this organization has already run. It must belong to the key’s organization: an id from another organization answers exactly as a non-existent one does, so existence cannot be probed.
| Field | Type | Meaning |
|---|---|---|
scored[] | array | One entry per title sent, IN THE ORDER SENT, each with `title` and `titleScore`. |
opportunityScore | 0-100 | score.overall for the TOPIC, from that analysis. Unchanged by anything here. |
titleStyle | object | null | How the first page's own titles are built. Null when the analysis has no ranking. |
titleScoreVersion | string | Store it beside any score you keep. |
Results are not ranked and not scored against each other. The same title with the same researchId always returns the same number, whatever else was in the request — which is what makes a score cacheable and explainable. Sort by titleScore.overall yourself for a ranking. POST /v2/titles does the opposite deliberately: there the titles compete to be recommended, so near-duplicates score each other down.
Optional subject affects clickAppeal only, which is relevance-gated — a title about a different subject than the research scores near zero there however well it reads. Send it when the video is narrower than the phrase that was analysed. A title scored here gets the same number the same title gets inside a recommendation.
/v2/health
Open, no key needed. Reports what is switched on: whether ranking data is available, the API contract and scoring versions in force, and the geographies supported.
curl https://app.hitsonar.com/v2/healthErrors
One envelope, whatever the route or the status:
{
"error": {
"code": "organization_limit",
"message": "This organization has used its analyses for the month.",
"type": "usage_limit_error",
"param": null,
"requestId": "req_9Kx3mZq2pR8vNc1w"
},
"usage": { "used": 50, "limit": 50, "resetsAt": "2026-10-01T00:00:00.000Z" }
}usage is present only when error.code is organization_limit or global_limit. Every error also sets X-Request-Id, matching error.requestId, on the response headers.
| Field | Type | Meaning |
|---|---|---|
unauthorized | 401 · authentication_error | Missing or unknown key. |
invalid_request | 400 · invalid_request_error | Phrase, q or title too short, an unsupported geo, or a researchId not available to your key. error.param names the field. |
insufficient_context | 422 · invalid_request_error | POST /v2/titles only. The request was well formed, and the title does not say enough about the video to research it. Send a description, or a more descriptive title. |
rate_limited | 429 · rate_limit_error | This key is sending requests too fast. See Retry-After. |
organization_limit | 429 · usage_limit_error | This organization's analyses for the month are used. |
global_limit | 429 · usage_limit_error | HitSonar's own monthly ceiling is reached. Not about your organization. |
service_unavailable | 503 · api_error | YouTube or Trends did not answer, or the usage check failed. Not charged. |
This is /v2, API contract 3.2.0. /v1 still returns its original, simpler shape -- { "error": "some string" } -- unchanged, for callers who integrated before this document existed; see Versioning. If code that already targets /v2 checks typeof body.error === "string", it needs updating; see the changelog for the date this shipped.
Rate limits & usage
Two different limits, on two different header families, so neither is mistaken for the other:
| Field | Type | Meaning |
|---|---|---|
X-RateLimit-* | 60 requests / minute, per key | How fast you may call. Carried by organization-key responses that have a rate-limit state, success or 429, so you see a limit coming rather than discovering it by being refused. An operator-configured key has no organization counter, and a 401 has no key to report one for. |
X-Usage-* | credits / month, per organization | Credits left this calendar month, in CREDITS rather than calls — divide by 5. Only on POST /v2/analyze; visible any time in the console too. |
usage.credits (response body) | the same pool | Returned by POST /v2/titles with used, limit, remaining, resetsAt and perCall. The same balance the headers report, in the body because that endpoint does not send usage headers. |
Credits
One pool per organization, per calendar month. A call costs 5 credits whatever it is: Free grants 50 credits (10 calls), Pro grants 2,000 (400 calls).
Counted: one uncached POST /v2/analyze, and one POST /v2/titles — the latter is one call however many topics it measures internally. Free: GET /v2/suggest, a cached repeat, a run that turns out provisional, and a run that fails: a reservation is taken before the work and given back if the work does not happen, so an outage never costs anything. Credits do not roll over.
Versioning
Three things that sound similar and are kept deliberately separate:
| Field | Type | Meaning |
|---|---|---|
/v1, /v2 | URL generation | Which family of endpoints, and which response shape, this is. A breaking change gets a new URL prefix, never a silent change inside an existing one -- see below. |
API_VERSION | currently "3.2.0" | The shape of /v2 requests and responses: field names, types, required-ness, enum values. In every /v2/health response as apiVersion. |
score.version (SCORING_VERSION) | currently "1.2.0" | The formula that turns signals into a HitSonar Score. Frozen once released; a new formula gets a new number. In every score, on both /v1 and /v2. |
A scoring change never moves API_VERSION: the response shape is unchanged. A response-shape change never moves score.version: nothing about how a score is computed changed. The two can move together in one release without being the same number, and usually will not be. Every developer-facing change of either kind is dated in the changelog, and the rules for what counts as breaking are in the versioning and deprecation policy.
/v1? It still works. Its requests, its success research payload, its error contract and its documented endpoint behaviour are frozen: the same bare { "error": "..." } shape it has always returned, no request id, no rate-limit headers -- see /v1/openapi.json for its own frozen spec. Backward-compatible status metadata has been added without touching an existing field: /v1/health gained apiVersion, reporting the frozen 1.0.0. It is deprecated (every /v1/* response now carries a Deprecation header) and will not gain new fields or endpoints, so new integrations should start on /v2 directly. There is no sunset date yet; one will be announced, with notice, once existing callers have had a chance to move. Details: the deprecation policy, or go straight to the /v1 to /v2 migration guide.OpenAPI & SDKs
The full machine-readable contract, generated from the same Zod schemas the server validates requests against and tests validate responses against (nothing here is retyped by hand):
/v2/openapi.json/v2/openapi.yaml, the same document
Point a generator (openapi-typescript, openapi-python-client, Postman’s importer, an LLM) at either and get typed shapes without guessing a field name.
Official SDKs
@hitsonar/sdk (npm) and hitsonar (PyPI): full types generated from the contract above, authentication, .analyze(), .suggest(), .health(), typed error classes, timeout handling, rate-limit and usage-limit awareness, and the request id on every error. Their own version is independent of API_VERSION and score.version, following normal semver for the package. Full guides: TypeScript SDK · Python SDK.
Single-file clients
Until the packages above are published, or if you would rather not add a dependency: two single-file clients, plain code, nothing to install beyond what is noted, nothing to trust but what you can read.
/**
* A single-file client. Copy this into your project -- it is not the published @hitsonar/sdk
* package, so there is no version to pin or registry to trust. Requires Node 18+ or any modern
* runtime with a global fetch. See "OpenAPI & SDKs" below for the real package.
*/
export type HitSonarScore = {
version: string;
overall: number;
grade: "A" | "B" | "C" | "D" | "F";
demand: number;
competition: number;
searchBand: { label: string; low: number; high: number | null } | null;
confidence: "low" | "medium" | "high";
};
export class HitSonarError extends Error {
constructor(message: string, public code: string, public status: number, public requestId: string | null, public body: unknown) {
super(message);
}
}
export function hitSonar(apiKey: string, baseUrl = "https://app.hitsonar.com") {
async function call(path: string, init?: RequestInit) {
const res = await fetch(baseUrl + path, {
...init,
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...init?.headers },
signal: AbortSignal.timeout(60_000),
});
const body = await res.json().catch(() => null);
if (!res.ok) throw new HitSonarError(body?.error?.message ?? `HitSonar ${res.status}`, body?.error?.code ?? "unknown", res.status, res.headers.get("X-Request-Id"), body);
return body;
}
return {
analyze: (phrase: string, opts: { geo?: "US"; fresh?: boolean } = {}) =>
call("/v2/analyze", { method: "POST", body: JSON.stringify({ phrase, ...opts }) }) as Promise<{ score: HitSonarScore; [k: string]: unknown }>,
suggest: (q: string, geo: "US" = "US") =>
call(`/v2/suggest?q=${encodeURIComponent(q)}&geo=${geo}`) as Promise<{ suggestions: { phrase: string; hits: number }[]; seedSuggestions: number | null }>,
};
}Changelog, methodology, policy
Status
A live read of /v2/health, refreshed when this page loads. There is no historical uptime record or incident log behind it: this shows whether the service is answering right now, not whether it was an hour ago.
