API reference
Your first request
Set your key in the environment, then look up a domain you already know. Every sample on this page reads it from STACKSCOPE_API_KEY.
/lookup is on every plan, so this is the shape of a first request whichever one you pick. A key comes with a subscription.
You get back the launch, when it shipped, where it was found, and the stack detected on it. A domain we have never crawled returns 200 with {"found": false}, which costs a credit and no distinct launch.
403, so you can always tell a plan problem from a key problem.
export STACKSCOPE_API_KEY="ss_live_..."
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/lookup?domain=arcloop.ai"
Overview
A metered JSON API over what our crawler has found on the new web. The data endpoints are read-only; watches are created and deleted through it too. It holds both reviewed launches and ordinary sites picked up by StackScope Discovery, and the wider web is currently the larger of the two. Every launch record identifies its population (the indie flag), its launch and discovery dates, where it was found, its category and country, and its liveness. (/tech is the exception: it lists technologies, not launches.) /lookup adds the stack we captured, plus optional infrastructure, email, agent and quality enrichment.
Reading a response
observedSince
launchedAt is when the product shipped. observedSince is when the row entered our corpus, which is usually later, sometimes by days. How much later depends on how a row was found, so treat it as a variable lag rather than a fixed offset. We have so far seen arrivals up to three weeks late; that is an observation, not a guaranteed maximum. Page incremental jobs on observedSince: a job asking for launches after the newest launchedAt it has seen will never be told about the rows that arrive afterwards carrying an older launch date.lastCrawledAt
crawlCount
lastAliveAt
firstCrawledAt is our first successful fetch, which trails observedSince because the row exists from the moment Discovery finds it and the crawl follows. lastCrawledAt is the latest full crawl; a crawlCount of 1 means crawled once. lastAliveAt is a different job: a lightweight liveness check that got a successful response. It runs on its own schedule, re-runs no technology detection, and therefore moves neither lastCrawledAt nor crawlCount. That is why lastAliveAt is routinely later than lastCrawledAt. All four are on /lookup only.lastSeenAt
stack[] entry: the first and latest crawl on which we detected that technology on that site. Derived from crawl dates, never internal detection timestamps, so re-detecting an old crawl never looks like new adoption. Not to be confused with /tech's firstLaunchAt, which is a launch date across all sites using a technology. They answer different questions and are named apart.high / medium / low, the best across matched rules for that tech. The same value the public launch page shows.categoryConfidence
categoryConfidence means we never classified the row. Country is different: a row we never estimated usually carries 0, so judge country by the score, not by null.category
true / false / null.min_country_confidence
/lookup, /launches, /tech/{slug}/launches and /export, and judge for yourself. min_category_confidence=0 withholds no category. The two are independent: the scores are not on the same scale, so one number for both would widen one dimension while narrowing the other. The category= and country= query filters are separate from disclosure: they always match at the public floors, whatever floors you set here.Dates and windows
Casing: JSON is camelCase in both directions (launchType in a body, hasWebhook in a response); query parameters and CSV headers are snake_case (launch_type, observed_since). The split is deliberate, so the same concept changes shape between a URL and a body.
Every timestamp we send is UTC, ISO 8601, with an explicit offset. Every date parameter is read as UTC: a bare 2026-04-01 means midnight UTC that morning, so passing a bare date from a machine in another timezone does not quietly shift the window.
Timestamp windows (since/until, observed_since/observed_until) are inclusive at the lower bound, exclusive at the upper. since=2026-04-01&until=2026-05-01 is the whole of April and no part of May, so consecutive calls where each until becomes the next since tile the corpus exactly once: no row served twice, none skipped.
/tech is the deliberate exception: its from and to name whole days rather than instants, and both ends are inclusive. from=2026-06-01&to=2026-06-30 is all of June, 30 June included. An exclusive upper bound on a parameter that takes a bare date is a trap, so that endpoint reads the way a date range reads.
Paging
count is the number of rows in this response, never a total for the query. A total would mean a second full count on every call, and we would rather serve the page.
List results sort newest first and break ties on an internal id, so the order is total rather than merely mostly-sorted. Within a fixed window that ordering is stable: repeating the same parameters against the same bounded range returns the same rows in the same order. Leave the upper bound open and new arrivals can land at the top between calls, which is why a polling job should freeze observed_until before it starts.
To walk a result set, prefer cursor over offset on /launches: pass back the nextCursor you were given, unchanged, and stop when a response has none. Offsets re-count from the top on every call, so a row arriving mid-walk shifts everything down and you re-read a row you already had. Treat the cursor as opaque; its contents are not part of the contract.
The cursor records where you got to, and carries a fingerprint of what you asked for, so send the original filters with every page and add the cursor alongside them. A mismatch is refused with 400 cursor_filter_mismatch before anything is charged, so a dropped filter cannot silently hand you a different population mid-walk.
For a polling job, fix both ends of the window before you start: read up to a boundary you choose (observed_until), page through that frozen range, and make this run's upper bound the next run's observed_since. Rows arriving mid-walk then land in the next run rather than shifting the one in progress.
What is in the corpus
Rows come from our own crawl of sites found by StackScope Discovery and from public launch directories. Two exclusions are worth knowing before you build on the numbers:
- Product Hunt and PeerPush listings are not sold. A launch we know about only from one of those platforms is not served. Where StackScope Discovery independently found the same site, the row is served as ours, with the platform's name, tagline and listing URL stripped: the host stands in for the name. So an aggregate here is never a Product Hunt or PeerPush aggregate. Product Hunt is excluded because their API terms require it; PeerPush while we wait for their agreement, since their listings reached us under a non-commercial understanding.
- Owner submissions are private. A site added by its own owner through the website is never served, on any plan.
Set against that, indie tells you which rows passed review as launches and which are ordinary sites Discovery picked up. Both are real data; they answer different questions, and a count that mixes them answers neither.
Versioning
/api/v1 is stable: no breaking field removals or type changes within v1. Additive fields may appear. A breaking change ships as /api/v2.
# reviewed launches only
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/launches?indie=true"
# the wider web our crawler found
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/launches?indie=false"
# omit it and you get both (the default).
# the wider web starts in July 2026, so on
# earlier windows indie changes nothing
indie answer "sites on the new web", not "launches". Both are legitimate; they are just different denominators, and mixing them answers neither question.
Authentication
Every request carries a bearer key in the Authorization header. Never put keys in the query string, where they land in access logs.
A key is ss_live_ plus 43 base64url characters (51 total). It is shown once at creation and stored only as a hash: it cannot be recovered, only revoked and re-minted from the keys page in your account. Keys carry scopes; plan and quotas live on your account, so extra keys never multiply the quota.
Error semantics
{"error": "invalid_or_missing_key"}. Re-mint it from the keys page in your account; a key is never recoverable, only replaced.{"error": "scope_not_on_plan"} means your plan does not include it, so changing plan is the fix; {"error": "scope_not_on_key"} means your plan does include it but this key was not granted it, so mint a key with the scope ticked. Each endpoint's scope is on its chip, and the plan matrix lists them together.{slug} that is not a public technology, and a watch that either does not exist or belongs to another account: the two are indistinguishable by design. Never used to signal an authentication or plan problem. Note /lookup on a domain we hold no launch for is not a 404: it is a 200 with {"found": false}.monthly_credits_exhausted or distinct_launch_ceiling_reached. Creating a watch past your plan's allowance also answers 429, with watch_limit_reached. A 429 with a short plain-text body, not JSON, is a different thing: the per-key rate limit, not your quota. See the meters.error explains the problem.Authorization: Bearer ss_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
{ "error": "monthly_credits_exhausted" }
Introspection. Confirm a key works and see its plan and scopes. Free, so it is safe for health checks and CI.
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/status"
{
"keyPrefix": "ss_live_AZTQ",
"plan": "pro",
"scopes": ["lookup", "trends"],
"status": "ok"
}
Looks up a domain and returns its launch record, the stack detected when it was crawled, and any requested enrichment. A full URL is accepted for convenience, but only the host is matched: the path you supply is ignored, and a bare domain also matches launches recorded on a path beneath it. Where several rows share a host, a homepage row wins, then the most recently launched. Subdomains are distinct hosts. A leading www. is folded away, so www.example.com and example.com are the same lookup, but app.example.com is a different site and will not resolve to the apex row, or the other way round. Every call costs one credit. Looking up a launch you have already touched this month costs no further distinct launch, so re-checking a domain you follow is cheap against the ceiling but not against credits. An unknown domain returns 200 {"found": false}.
infra, email, quality, agent, or all. agent reports what the site tells AI crawlers, as booleans: whether its robots rules block any known AI agent, whether it publishes an llms.txt, and whether a robots.txt and sitemap exist at all. Omitted blocks are null. email is parsed posture only, never raw DNS records; quality is scores only, never the signal breakdowns.min_category_confidence
min_country_confidenceoptional
0 to 1. They control disclosure, not filtering: a row under the floor is still returned, with category or country null and its confidence score still present. Defaults are the public site's: category 0.9, country 0.45. min_confidence sets both but can only ever lower a floor; the two specific parameters raise or lower theirs outright and win where both are given. See reading a response.curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/lookup?domain=arcloop.ai&include=infra,quality"
const res = await fetch(
"https://stackscope.dev/api/v1/lookup?domain=arcloop.ai&include=infra,quality",
{ headers: { Authorization: "Bearer " + process.env.STACKSCOPE_API_KEY } }
);
const launch = await res.json();
import os, requests
r = requests.get(
"https://stackscope.dev/api/v1/lookup",
params={"domain": "arcloop.ai", "include": "infra,quality"},
headers={"Authorization": "Bearer " + os.environ["STACKSCOPE_API_KEY"]},
)
launch = r.json()
{
"shortId": "i3khvywy",
"slug": "arcloop-ai",
"name": "Arcloop AI",
"tagline": "Make short film, animation, and drama series with one click",
"url": "https://arcloop.ai/en-US",
"source": "peerpush",
"launchType": "directory",
"indie": true,
"sourceUrl": "https://peerpush.com/p/arcloop-ai",
"additionalSources": [],
"launchedAt": "2026-07-03T00:05:00+00:00",
"observedSince": "2026-07-03T00:30:22.000+00:00",
"category": null,
"categoryConfidence": 0.52,
"country": null,
"countryConfidence": 0,
"livenessStatus": "alive",
"lastAliveAt": "2026-07-15T02:19:42.978+00:00",
"screenshotUrl": "https://stackscope.dev/screenshots/i3khvywy.webp",
"firstCrawledAt": "2026-07-03T00:50:29.033+00:00",
"lastCrawledAt": "2026-07-03T00:50:29.033+00:00",
"crawlCount": 1,
"stack": [
{
"provider": "Next.js",
"slug": "nextjs",
"category": "meta_framework",
"layer": "meta",
"logoUrl": "https://stackscope.dev/tech-logos/nextjs.webp",
"confidence": "high",
"firstSeenAt": "2026-07-03T00:50:29.033+00:00",
"lastSeenAt": "2026-07-03T00:50:29.033+00:00"
},
{
"provider": "Sentry",
"slug": "sentry",
"category": "error_tracking",
"layer": null,
"logoUrl": "https://stackscope.dev/tech-logos/sentry.webp",
"confidence": "high",
"firstSeenAt": "2026-07-03T00:50:29.033+00:00",
"lastSeenAt": "2026-07-03T00:50:29.033+00:00"
}
],
"infra": {
"asn": "16509",
"asnOrg": "AMAZON-02 - Amazon.com, Inc., US",
"ipCountry": "US",
"sslIssuer": "Amazon",
"sslExpiry": "2026-10-08T23:59:59+00:00",
"sslValidFrom": "2026-03-25T00:00:00+00:00",
"domainCreatedAt": "2025-10-11T09:13:38.894+00:00",
"domainExpiresAt": "2027-10-11T09:13:38.894+00:00",
"registrar": "GoDaddy.com, LLC",
"registrantCountry": "US"
},
"email": null,
"quality": {
"securityScore": 3,
"performanceScore": 50,
"aiBuiltScore": 18,
"stackScopeScore": 83,
"launchReadinessScore": 90,
"aiStance": "open",
"stackClassification": "unknown",
"isGreenHosted": false
},
"agent": null
}
{ "found": false }
The public fingerprint catalogue, ranked by distinct launch count.
from alone means since, to alone means up to. The ranking follows the windowed count, so the order changes with the window. With neither, the counts are lifetime totals and the response is a bare array.firstLaunchAt of 2026-04-01 therefore means "at or before the start of our window", not that the technology was first adopted then. Nothing before that date exists to be queried, and from earlier than it returns the same as from=2026-04-01.
indie=true. Both are given because filtering changes which one is the right denominator.
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/tech?limit=100"
[
{
"provider": "Tailwind CSS",
"slug": "tailwindcss",
"category": "css_framework",
"layer": null,
"launchCount": 117190,
"indieLaunchCount": 55083,
"firstLaunchAt": "2026-04-01T00:00:32.939+00:00"
}
]
{
"from": "2026-06-01",
"to": "2026-06-30",
"corpusStart": "2026-04-01",
"count": 1,
"tech": [
{
"provider": "Next.js",
"slug": "nextjs",
"category": "meta_framework",
"layer": "meta",
"launchCount": 1199,
"indieLaunchCount": 402,
"firstLaunchAt": "2026-04-01T00:00:32.939+00:00"
}
]
}
Launches using a tech, newest first. An unknown, internal, or inactive slug returns 404, so the internal namespace cannot be enumerated.
offset only, and bounds on the launch date rather than arrival, which is the combination the paging section warns against. For a standing job use /launches?tech=&observed_since= with a cursor: same rows, safe to resume. Use this one for a one-off read of a technology.
quality attaches the same banded quintiles as on /launches (1 lowest to 5 highest), never precise scores. Without it the quality property is present and null.min_category_confidence
min_country_confidenceoptional
0 to 1. They control disclosure, not filtering: a row under the floor is still returned, with category or country null and its confidence score still present. Defaults are the public site's: category 0.9, country 0.45. min_confidence sets both but can only ever lower a floor; the two specific parameters raise or lower theirs outright and win where both are given. See reading a response.curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/tech/nextjs/launches?since=2026-07-01&limit=50"
{
"slug": "nextjs",
"count": 1,
"offset": 0,
"launches": [
{
"shortId": "9gu5svmx",
"slug": "opencapital",
"name": "OpenCapital",
"tagline": "Fast stock analysis for investors who care about the numbers",
"url": "https://www.opencapital.sh",
"source": "peerpush",
"launchType": "directory",
"indie": true,
"launchedAt": "2026-07-28T00:00:36.455+00:00",
"observedSince": "2026-07-28T00:31:06.656+00:00",
"category": null,
"categoryConfidence": null,
"country": "US",
"countryConfidence": 0.83,
"livenessStatus": "alive",
"quality": null
}
]
}
The pull feed: new launches, filterable, newest first. Poll it on your own schedule, or let Stackdar push instead.
observed_since, not since. Rows can reach us days after the product launched, and we have seen arrivals up to three weeks late. A job that asks for since= its newest launchedAt will never see those rows: they carry an older launch date than one it has already passed.
cursor walks backwards through arrivals from the top of the window. Inclusive lower bound, exclusive upper.nextCursor from your last response, unchanged, to get the next page. A response with no nextCursor was the last page. Opaque: do not parse it or construct one. Supersedes offset, and the two are not combined. A cursor is bound to the filters it was issued under. Repeat every filter from the first request on each page and add the cursor to it. Change or drop one and the call returns 400 cursor_filter_mismatch rather than quietly serving a different population. When a cursor is present, offset has no effect and is omitted from the response. limit remains available because it changes only the page size, not which rows are eligible.directory for rows found on a launch directory or community, discovered for rows StackScope Discovery found first. Anything else returns 400. "Directory" is the site's own word for these sources and covers communities like Hacker News as well as listing sites; see the launch directories hub. /trends calls the same population the platforms lens.true returns only rows our review classed as launches, false only the wider web. Omit it and you get both, which is the default. Note the wider-web population starts in July 2026: for windows before that the two are the same set, so this filter changes nothing.US), gated at ≥ 0.45 confidence.quality attaches banded quality (quintiles, 1 lowest to 5 highest), never precise scores.min_category_confidence
min_country_confidenceoptional
0 to 1. They control disclosure, not filtering: a row under the floor is still returned, with category or country null and its confidence score still present. Defaults are the public site's: category 0.9, country 0.45. min_confidence sets both but can only ever lower a floor; the two specific parameters raise or lower theirs outright and win where both are given. See reading a response.# Freeze the window at the start of the run, so
# rows arriving mid-walk cannot shift the pages.
FROM=2026-07-27T00:00:00Z # last run's TO
TO=2026-07-28T01:00:00Z # now, rounded
Q="observed_since=$FROM&observed_until=$TO&country=US&indie=true"
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/launches?$Q"
# Every page repeats the SAME filters and adds the
# cursor you were given. Stop when nextCursor is absent.
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/launches?$Q&cursor=eyJjdXJzb3IiOiJvcGFxdWUifQ"
# Next run: FROM becomes this run's TO.
{
"count": 1,
"nextCursor": "eyJjdXJzb3IiOiJvcGFxdWUifQ",
"launches": [
{
"shortId": "t4jhbov3",
"slug": "paperchat-ai-customer-support",
"name": "Paperchat - AI customer support",
"tagline": "Train AI from your data, human handoff.",
"url": "https://www.paperchat.co",
"source": "peerpush",
"launchedAt": "2026-07-28T00:00:35.404+00:00",
"observedSince": "2026-07-28T00:31:06.763+00:00",
"category": null,
"categoryConfidence": null,
"country": "US",
"countryConfidence": 0.83,
"launchType": "directory",
"indie": true,
"livenessStatus": "alive",
"quality": null
}
]
}
Weekly distinct counts for a technology across the population the lens selects, over the full history available for that lens, plus methodology annotations across the same range. The public chart trims to a 90-day display window; this endpoint does not, so you get the series back to the start of the corpus.
Buckets are weeks starting Monday, dated by the launch date. The first and last buckets are usually partial: the first because the corpus opens on 1 April 2026 and that Monday falls before it, the last because the current week is still filling. Drop both before charting a rate, or the line appears to start low and end in a cliff.
platforms (default) is launches that arrived from a launch directory or community. all adds the ones our discovery pipeline found and review promoted. discovery is that promoted set on its own. wider_web is the rest of what discovery found: real sites, not product launches. Anything else returns 400. Counts cover the servable corpus only, so a launch we know about solely from Product Hunt or PeerPush is absent here even where the public chart counts it.annotations before drawing conclusions from a step in the line. A jump may be a coverage change, not organic adoption.
wider_web counts sites rather than launches, and it is much the largest population, so a series under it is not comparable with one under platforms. The response names the lens back to you for that reason. all excludes wider_web.
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/trends?tech=nextjs"
{
"tech": "nextjs",
"lens": "platforms",
"lensLabel": "Launch platforms",
"weekly": [
{ "week": "2026-03-30T00:00:00+00:00", "launches": 865 },
{ "week": "2026-04-06T00:00:00+00:00", "launches": 1584 }
],
"annotations": [
{
"date": "2026-04-10T00:00:00+00:00",
"label": "Detection coverage improved for ..."
}
]
}
CSV of the feed or a tech's launches. Returns text/csv with a Content-Disposition filename.
launches (default) or tech-launches.tech-launches; a filter for launches.observed_since, observed_until,
launch_type, indieoptional
/launches, including the confidence floors, for type=launches. On type=tech-launches only since, until and launch_type apply; use type=launches&tech= for the full filter set. Filtering here rather than after the download keeps rows off your distinct-launch meter.quality adds banded quality columns (quintiles, 1 lowest to 5 highest).min_category_confidence
min_country_confidenceoptional
0 to 1. They control disclosure, not filtering: a row under the floor is still returned, with category or country null and its confidence score still present. Defaults are the public site's: category 0.9, country 0.45. min_confidence sets both but can only ever lower a floor; the two specific parameters raise or lower theirs outright and win where both are given. See reading a response.liveness_status is the one to watch, since liveness is re-checked on its own schedule long after the crawl.
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/export?tech=nextjs&since=2026-07-01" -o launches.csv
short_id, slug, name, tagline, url, source,
launch_type, indie, launched_at, observed_since,
category, category_confidence,
country, country_confidence, liveness_status
# with include=quality, appended:
security_band, performance_band, ai_built_band,
stackscope_band, readiness_band, green_hosted
Watches
Stackdar watches, managed with a key. The same objects the watches page in your account creates, so a watch made here shows up there and the other way round. Watches are the one part of StackScope you never have to write code for: the account portal creates, tests and deletes them, sets the delivery channel and schedule, and shows the signing secret once. This section is for wiring them into something else.
Every watch on your account, newest first. The signing secret is never echoed back, and neither is the full webhook URL: hasWebhook says whether one is set and webhookHost names the host, which is enough to tell watches apart without exposing a path or query that may carry a token.
Watches are immutable. There is no update: to change a filter, a channel or a schedule, delete the watch and create a new one. Creating a new webhook watch mints a new signing secret, so plan for the rotation. Nothing stops you creating two watches with identical filters, and both will fire.
consecutiveFailures is the run of failed deliveries. It resets to zero on any 2xx, and a watch that reaches twenty is switched off, which shows as isActive: false.
{
"count": 1,
"watches": [
{
"id": 42,
"channel": "webhook",
"digestCadence": "instant",
"isActive": true,
"consecutiveFailures": 0,
"createdAt": "2026-07-27T18:04:11.787+00:00",
"hasWebhook": true,
"webhookHost": "hooks.example.com",
"filters": {
"techs": ["nextjs"],
"categories": null,
"countries": ["GB"],
"source": null,
"launchType": null,
"indie": null
}
}
]
}
Creates one watch. Your plan caps how many you may hold and how many technologies each may name; over either, the call returns 429 watch_limit_reached or a 400 saying which bound you crossed.
{"countries":["GB"],"categories":["fintech"]} is a perfectly narrow watch and works on any plan. Only a watch that names nothing in any dimension matches every eligible launch, and that one is the Firehose product.techs matches any one. Use it to narrow to a segment: {"techsAll":["wordpress","woocommerce"]}. These count toward your per-watch technology allowance, because they name what you are watching.{"techs":["wordpress"],"techsExcluding":["yoast"]} is the displacement query, sites running the one and not the other. Exclusions are free and never count against your allowance, so that example fits on a one-technology plan. Two limits apply. The filter means not detected, not not installed, so only technologies we detect reliably may be excluded and anything weaker is refused rather than served as a guess. And a watch that excludes without including anything is still the whole stream minus a slice, so it is gated like any other unfiltered watch.hackernews or first_party. Finer than launchType, which groups sources into directory and discovered. producthunt, peerpush and self_add are refused: none is servable, so a watch on them could never fire.directory watch indie does nothing, because everything arriving from a launch platform is already indie.200 with the new watch, not 201, and no Location header. The body carries webhookSecret exactly once.webhook (default), slack or email. The every-launch stream cannot be delivered by email.instant (default) delivers each match as it lands. daily and weekly batch them into one digest. Email cannot carry the instant stream, so on channel=email an omitted or instant cadence is stored as daily: the value you read back is the one that will actually be used. Webhook and Slack keep instant.digestHour
digestDayOfWeek
digestCsvoptional
digestTimezone an IANA zone name, default Etc/UTC. digestHour integer 0 to 23, default 8, local to that zone. digestDayOfWeek integer 0 to 6 with 0 = Sunday, weekly only, default Monday. digestCsv boolean, attaches the list as a CSV, up to 5,000 rows. Out-of-range hours and weekdays are clamped rather than rejected. All four are ignored when the cadence is instant. Daylight saving is handled, so an 08:00 digest stays 08:00 all year.webhook channel, and we store only a hash of it. The signature is HMAC-SHA256(key = SHA-256(secret), message = raw request body), lowercase hex. Note the key is the SHA-256 digest of your secret, not the secret itself: we hold only that digest, so it is what we can sign with. HMAC with the raw secret and nothing will ever match. See delivery semantics. If you lose it, delete the watch and create another; there is no way to reissue it.
curl -X POST -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"techs":["nextjs"],"countries":["GB"],"channel":"webhook",
"webhookUrl":"https://example.com/hooks/stackscope"}' \
"https://stackscope.dev/api/v1/watches"
{
"id": 42,
"channel": "webhook",
"digestCadence": "instant",
"isActive": true,
"webhookSecret": "shown once, never again"
}
Deletes one of your watches and stops it firing immediately. Its filters and signing secret go with it. A watch that is not yours, or does not exist, returns 404: the two are deliberately indistinguishable.
{ "deleted": true, "id": 42 }
Scopes & quotas
Two conditions must be met before a call goes through: your plan must include the scope, and the key must have been granted it. /status still requires a valid key, but needs no granted scope and consumes no quota. A call that fails either test returns 403, naming which half was short.
The Indie plan can retrieve feed data through /export. Indie does not carry feed, so /launches answers 403, but /export?type=launches is on every plan and returns the same rows as CSV. That is not an oversight: the export is capped per call, carries banded quality rather than precise scores, and is watermarked, so it suits a periodic pull while the JSON feed stays a Pro capability. Ticking a scope when you create a key does not add it to your plan: a key can only ever narrow what the plan already allows, which is what makes a per-service key with one scope useful. The table below is the plan half.
Two meters
429 monthly_credits_exhausted.429 distinct_launch_ceiling_reached. Note that /export costs a single credit whatever its size, but every row it returns counts here, so one large export can spend a whole period of this meter at once.X-StackScope-Credits-Limit and X-StackScope-Credits-Remaining, including the 429 that refuses you, so you never have to guess how close a wall is. X-StackScope-Launches-Limit and X-StackScope-Launches-Remaining ride along when the request touches launch rows and the plan has a ceiling.429 with a short plain-text body rather than JSON, which is how you tell it apart from a quota refusal: those always carry JSON naming the meter. Retry with backoff.Plans
| Plan | Endpoints | Monthly credits | Distinct-launch ceiling | Watches | Techs per watch |
|---|---|---|---|---|---|
| indie | /lookup, /export, /watches | 2,000 | 5,000 | 1 | 1 |
| pro | /lookup, /tech, /launches, /trends, /export, /watches | 12,000 | 50,000 | 5 | 2 |
| firehose | /lookup, /tech, /launches, /trends, /export, /watches | 60,000 | unlimited | unlimited | 50 |
Precise quality scores (0 to 100) come only from the metered /lookup; bulk payloads return quintile bands instead, 1 lowest to 5 highest.
Prices for each plan, and what the data covers, are on the Data and API page.
lookup /lookup
lists /tech
/tech/{slug}/launches
feed /launches
trends /trends
export /export
watches /watches (Stackdar)
Stackdar
Stackdar turns the launch stream into standing alerts: it pings you when a new launch ships matching your filters. Manage watches from your account or with the keyed API (scope watches).
What fires
A new_launch event fires when a launch first becomes eligible: it is public and its first successful crawl carries a live detection. An alert can trail the crawl by up to a few hours while enrichment finishes, and a discovered row becomes eligible only once review keeps it. Re-detections of old crawls never fire, so an alert always means "a new launch shipped with X", not "detection coverage improved". Matching is AND across filter dimensions; an empty dimension is a wildcard, and a tech filter matches any of the watched slugs.
Channels
daily or weekly. Use the Send test button on your watches page to confirm delivery.Webhook and Slack URLs must be HTTPS and resolve to a public IP. Private, loopback, and reserved ranges are rejected at creation, and each delivery re-resolves and pins to a checked public IP.
Delivery semantics
X-StackScope-Delivery.POST <your webhook URL>
Content-Type: application/json
X-StackScope-Event: new_launch
X-StackScope-Delivery: <id>
X-StackScope-Signature: <hex>
{
"event": "new_launch",
"deliveryId": 123,
"launch": { /* the /lookup shape. infra, email,
quality and agent are always present
and always null on a webhook. */ }
}
// rawBody = the exact bytes received,
// before any JSON parsing or re-encoding.
const key = crypto.createHash("sha256")
.update(secret).digest();
const expected = crypto
.createHmac("sha256", key)
.update(rawBody).digest();
// Decode first: timingSafeEqual THROWS on a
// length mismatch, so a truncated or missing
// header would crash instead of failing shut.
const got = Buffer.from(headerSig ?? "", "hex");
const ok = got.length === expected.length &&
crypto.timingSafeEqual(got, expected);