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=weightlift.dev"
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, quality and contact 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. The lag varies with how a row was found; we have seen arrivals up to three weeks late, and that is not a guaranteed maximum. Page incremental jobs on observedSince: a job asking for launches after the newest launchedAt it has seen never hears about rows that arrive later carrying an older launch date. See paging.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.high / medium / low, the best across matched rules for that tech. The same value the public launch page shows.categoryConfidence
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 because the scores are not on the same scale. 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.
Paging
count is the number of rows in this response, never a total for the query.
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 either meter is touched, 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.
End that window short of the present. A row is stamped with observedSince when we collect it and becomes visible when its batch commits, a little later. A window ending at "now" can therefore gain rows after you have read it, and those are lost for good: your next run starts at this run's upper bound, and they carry an observedSince below it. Hold the bound back by several minutes; fifteen is a comfortable margin, and the cost is latency rather than lost rows.
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 listings are not sold. A launch we know about only from Product Hunt 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. Product Hunt is excluded because their API terms require it. PeerPush was held out on the same terms until they gave permission on 4 August 2026, and is now served.
- 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.
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". They are different denominators; a count that mixes 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"}.{"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, contact, or all. contact is the site's own published contact route and needs the contacts scope; note it is a different thing from email, which is this API's DMARC and MTA-STS posture. 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. There is no floor by default, so every estimate we hold is served with its confidence and you apply your own threshold. Set min_category_confidence=0.9 and min_country_confidence=0.45 to match the figures published on the public site. min_confidence sets both; the two specific parameters win where both are given. See reading a response.curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/lookup?domain=weightlift.dev&include=infra,quality"
const res = await fetch(
"https://stackscope.dev/api/v1/lookup?domain=weightlift.dev&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": "weightlift.dev", "include": "infra,quality"},
headers={"Authorization": "Bearer " + os.environ["STACKSCOPE_API_KEY"]},
)
launch = r.json()
{
"shortId": "7n9h5k4n",
"slug": "weightlift",
"name": "Weightlift",
"tagline": "Show HN: Weightlift - the missing package manager for model weights",
"url": "https://weightlift.dev/",
"source": "hackernews",
"launchType": "directory",
"sourceUrl": "https://news.ycombinator.com/item?id=49132848",
"additionalSources": [],
"launchedAt": "2026-08-01T09:55:24+00:00",
"category": null,
"livenessStatus": null,
"lastAliveAt": null,
"screenshotUrl": "https://img.stackscope.dev/7n9h5k4n.webp",
"firstCrawledAt": "2026-08-01T12:56:43.997488+00:00",
"lastCrawledAt": "2026-08-01T12:56:43.997488+00:00",
"crawlCount": 1,
"stack": [
{
"provider": "Google APIs",
"slug": "google-apis",
"category": "api",
"layer": null,
"logoUrl": "https://stackscope.dev/tech-logos/google-apis.webp",
"confidence": "high",
"firstSeenAt": "2026-08-01T12:56:43.997488+00:00",
"lastSeenAt": "2026-08-01T12:56:43.997488+00:00"
},
{
"provider": "Fastly",
"slug": "fastly",
"category": "cdn",
"layer": null,
"logoUrl": "https://stackscope.dev/tech-logos/fastly.webp",
"confidence": "high",
"firstSeenAt": "2026-08-01T12:56:43.997488+00:00",
"lastSeenAt": "2026-08-01T12:56:43.997488+00:00"
}
],
"infra": {
"asn": "54113",
"asnOrg": "FASTLY - Fastly, Inc., US",
"ipCountry": "US",
"sslIssuer": "Let's Encrypt",
"sslExpiry": "2026-10-30T00:56:36+00:00",
"sslValidFrom": "2026-08-01T00:56:37+00:00",
"domainCreatedAt": "2026-08-01T01:43:12.268+00:00",
"domainExpiresAt": "2027-08-01T01:43:12.268+00:00",
"registrar": "Namecheap Inc.",
"registrantCountry": null
},
"email": null,
"quality": {
"securityScore": 0,
"performanceScore": 92,
"aiBuiltScore": 10,
"stackScopeScore": 25,
"launchReadinessScore": 88,
"aiStance": "no stance",
"stackClassification": "unknown",
"isGreenHosted": false
},
"agent": null,
"categoryConfidence": 0.59,
"indie": true,
"country": null,
"countryConfidence": 0,
"observedSince": "2026-08-01T12:32:15.006597+00:00"
}
{ "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.has_contact_urloptional
true returns only launches whose site publishes a role email address (or its own contact page); false returns only those that do not. Omit for no filter, which is not the same as false. Both together mean both. Needs the contacts scope. A launch on a path under someone else's host is never counted as having a contact, because the address belongs to the host rather than to that launch.min_category_confidence
min_country_confidenceoptional
0 to 1, exactly as on /lookup: they control disclosure, not filtering. 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": "1j0cal6y",
"slug": "echo-wander-audio-stories-that-listen-back",
"name": "Echo Wander - Audio stories that listen back.",
"tagline": "A romantasy you play with your voice. Authored scenes, AI-generated bridges.",
"url": "https://echowander.app/",
"source": "first_party",
"launchedAt": "2026-08-01T17:35:03+00:00",
"category": "content",
"livenessStatus": null,
"quality": null,
"launchType": "discovered",
"indie": false,
"categoryConfidence": 1,
"country": null,
"countryConfidence": null,
"observedSince": "2026-08-01T17:35:45.781582+00:00"
}
]
}
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. Set observed_until short of the present, never to "now": see paging.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. 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; it changes only the page size.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. 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.keywords=photogrammetry, or keywords=photogrammetry,drone%20surveying. Case-insensitive; a multi-word entry is a phrase; each 3 to 60 characters, up to ten. The same rule a watch's keywords use, so a word that filters here is the word a watch would fire on. A term outside the rule returns 400 invalid_keywords rather than being dropped.quality attaches banded quality (quintiles, 1 lowest to 5 highest), never precise scores.has_contact_urloptional
true returns only launches whose site publishes a role email address (or its own contact page); false returns only those that do not. Omit for no filter, which is not the same as false. Both together mean both. Needs the contacts scope. A launch on a path under someone else's host is never counted as having a contact, because the address belongs to the host rather than to that launch.min_category_confidence
min_country_confidenceoptional
0 to 1, exactly as on /lookup: they control disclosure, not filtering. See reading a response.# Freeze the window at the start of the run so rows
# arriving mid-walk cannot shift the pages, and end it
# short of now so the range has settled before you read it.
FROM=2026-07-27T00:00:00Z # last run's TO
TO=2026-07-28T01:00:00Z # now minus 15 minutes, NOT now
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,
"offset": 0,
"nextCursor": "bDoxNzg1NjA1Mzc0MDAwOjg1NjM3NDozMDkzMWI5ZQ",
"launches": [
{
"shortId": "iuvrduuj",
"slug": "flip-the-city",
"name": "FLIP the city",
"tagline": "Unlock hidden stories and follow location-based audio walks.",
"url": "https://flipthecity.tech/",
"source": "first_party",
"launchedAt": "2026-08-01T17:29:34+00:00",
"category": null,
"livenessStatus": null,
"quality": null,
"launchType": "discovered",
"indie": true,
"categoryConfidence": 0.74,
"country": null,
"countryConfidence": null,
"observedSince": "2026-08-01T17:30:09.106321+00:00"
}
]
}
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 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 ..."
}
]
}
The stack of a set of sites as counts and shares rather than rows: how many of the sites matching your filters carry anything in each kind of technology (payments, analytics, hosting, cms), and which technologies inside each kind, ranked. "Of the sites built with Ghost, what share take payments, and through whom" is one call.
Aggregate only, so it returns no per-launch rows and never consumes the distinct-launch ceiling. Each distinct set of filters counts as one Explore slice against your plan's monthly allowance the first time it is worked out; the same filters asked again inside ten minutes, by API, MCP or the portal, come from cache and count nothing.
Takes the /launches filters by the same names (tech, keywords, category, country, launch_type, indie, since, until, observed_since, observed_until, has_email, has_contact_url, min_country_confidence) and one of its own:
payments. Omit for every kind. This is the technology's category as /tech lists it, not the site's category. An unknown value returns an empty groups and names the kinds present in note.share is over every matching site, not over sites with a detection, and the technology you filtered on is left out of its own breakdown.
sampled: true and countedOver. A set that cannot be summarised in time returns 400 slice_too_broad; narrow it with a date range.
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
"https://stackscope.dev/api/v1/breakdown?tech=ghost&technology_category=payments"
{
"filters": { "tech": "ghost", "technology_category": "payments", ... },
"sites": 1029,
"countedOver": 1029,
"sampled": false,
"population": "sites matching the filters that StackScope has crawled",
"caveat": "Technologies detected on each site's crawled page. ...",
"groups": [
{
"technologyCategory": "payments",
"sites": 133,
"share": 0.129,
"technologies": [
{ "technology": "stripe", "name": "Stripe", "sites": 131, "share": 0.127 },
{ "technology": "paddle", "name": "Paddle", "sites": 1, "share": 0.001 }
]
}
],
"note": null
}
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).has_contact_urloptional
true returns only launches whose site publishes a role email address (or its own contact page); false returns only those that do not. Omit for no filter, which is not the same as false. Both together mean both. Needs the contacts scope. A launch on a path under someone else's host is never counted as having a contact, because the address belongs to the host rather than to that launch.min_category_confidence
min_country_confidenceoptional
0 to 1, exactly as on /lookup: they control disclosure, not filtering. 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 the contacts scope, inserted after url:
contact_email, contact_url, contact_checked_at
# with include=quality, appended:
security_band, performance_band, ai_built_band,
stackscope_band, readiness_band, green_hosted
The contact columns follow the contacts scope, not include. They are present whenever your key carries it, even where no row on that page has an address, so parsing by position stays stable. An empty pair with a contact_checked_at means the site was examined and publishes nothing; an empty contact_checked_at means it has not been examined.
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"],
"keywords": null,
"categories": null,
"countries": ["GB"],
"source": null,
"launchType": null,
"indie": null,
"hasEmail": null,
"hasContactUrl": 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 towards 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.app does not match "happy" or "apple". A multi-word entry is a phrase. Each must be 3 to 60 characters. {"keywords":["photogrammetry"]} is a complete watch on any plan, and {"techs":["stripe"],"keywords":["photogrammetry"]} fits on a one-technology plan, because keywords have their own per-watch allowance of the same size rather than sharing the technology one. A word so common that a month of it would exceed your launch ceiling is refused with a 400 carrying the estimate, rather than created and paused later.hackernews or first_party. Finer than launchType, which groups sources into directory and discovered. producthunt and self_add are refused: neither 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.true fires only on launches whose site publishes a role email address, or its own contact page; false only on those that do not; leave one out for no filter on it. Both set means both. The same test as has_email and has_contact_url on /launches, so a slice filtered there and saved as a watch fires on the same sites. Needs the contacts scope, and is refused with a 400 without it rather than dropped. The email digest's CSV carries the address and the page for every match.200 with the new watch, not 201, and no Location header. The body carries webhookSecret exactly once.webhook (default), slack, email or portal. portal delivers nothing: matches are recorded and read in your account, and they count against the distinct-launch allowance exactly as any other channel does. The every-launch stream cannot be delivered by email or portal, which are both read by a person.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. An HMAC keyed with the raw secret will never 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. 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. Stackdar spends this meter too. A launch delivered to a watch is the same record a lookup returns, so it is recorded the same way; it costs no credit, because you did not make a request for it. A watch on a very common technology can therefore use a whole period in a few days, which is what the estimate button on the Stackdar page is for. When the ceiling is reached the watch is paused until your period resets, and the portal shows it. Launches shipping during the pause are not queued for later delivery: a resumed watch starts from the launches shipping at the reset.X-StackScope-Credits-Limit and X-StackScope-Credits-Remaining, including the 429 that refuses you. 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 | 3,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 |
Keywords per watch follow the same number as techs per watch, on a separate allowance, so a technology and a keyword fit together on every plan.
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
/breakdown
export /export
watches /watches (Stackdar)
contacts no path of its own
contacts is the one scope that does not map to a path. It unlocks the contact block on /lookup, the contact columns in /export, and the has_email and has_contact_url filters wherever they are offered. Without it those filters answer 403 rather than being ignored, because a filter you cannot see the result of is still a way of reading the data.
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. A keyword filter matches any of its words, as whole words in the launch's name, tagline, page title, first heading or meta description; the delivery then says which word matched, as matchedKeywords on the webhook payload, a matched: suffix on the Slack line and a Matched: line in the digest.
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 address; the exact rules are under webhookUrl.
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. */ },
"matchedKeywords": ["photogrammetry"] /* only on a
watch that has keywords; absent otherwise */
}
// 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);
What a record means
A record is a launch-time observation. It tells you what a site shipped with when it launched, not a guess about what it runs today. Every payload carries observedSince, lastCrawledAt and crawlCount, so you can see when it was observed, when it was last crawled and how many times we have crawled it.
There is no scheduled recrawl of the back catalogue, so this data does not support stack-change alerts on existing domains.
Sites under liveness tracking are checked daily. Those changes are launch metadata, not an uptime signal. Quality scores are exact in per-domain lookups and grouped into five bands in bulk results, on the rows that carry the deeper analysis.
Aggregate counts follow the same rules as the public site for hidden and duplicate records, but they are not limited to indie launches. A count here will therefore usually be larger than the equivalent figure on the public site. Add indie=true to match the public pages.
Launch types
Every launch carries a launch type, and you can filter every list by it:
Sites that owners submit privately to StackScope for a readiness check are not part of the paid dataset at all.