API reference

Technographics for the new web: what the company that launched this week runs, as JSON.

Base URLhttps://stackscope.dev/api/v1
AuthBearer ss_live_…
FormatJSON · GET, plus watch writes
OpenAPIopenapi.json 3.1

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.

StackScope Data is a paid API. Keys come with a subscription: see plans and pricing. Endpoints outside your plan answer 403, so you can always tell a plan problem from a key problem.
bash
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

launchedAt
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.
firstCrawledAt
lastCrawledAt
crawlCount
lastAliveAt
A launch is usually crawled once, at launch. 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.
firstSeenAt
lastSeenAt
On a 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.
confidence
high / medium / low, the best across matched rules for that tech. The same value the public launch page shows.
countryConfidence
categoryConfidence
Always sent, even when the value itself is withheld: a numeric score beside a null value means the estimate fell below the floor. A null 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.
country
category
By default country appears only at confidence ≥ 0.45 and category only at ≥ 0.9, which are the same floors the public site applies, so a response here never names a country the launch's own page withholds. Green hosting is true / false / null.
min_category_confidence
min_country_confidence
Lower (or raise) either floor on /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.
min_confidence
Shorthand for both. It only ever lowers a floor, never raises one, so asking to see further into the low-confidence tail can never cost you data you would have received by default. Use the two parameters above to raise a floor.
Point-in-time data. There is no scheduled recrawl of existing launches, so the API reports "this launch shipped with X", not "tech changed on domain Y". A launch is usually crawled once, around the time it launches.
Never returned: detection internals (rule patterns, evidence values), internal or inactive fingerprints, private tokens, hidden or owner-only launches, scoring-signal breakdowns, and raw DNS records. The API returns detection results, not detection methods.

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.

URLs are not safety-checked. We record what the crawler found. Nothing in the corpus is screened for malware, phishing, fraud or scams, and appearing here is not an endorsement. Much of the corpus is the wider web rather than reviewed launches. If your system fetches these URLs automatically, or renders them to your users, sandbox it and do your own reputation check first.

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.

bash · the two populations
# 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
Counts built without 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

401
No usable key: missing, malformed, revoked, or belonging to a suspended account. Body is {"error": "invalid_or_missing_key"}. Re-mint it from the keys page in your account; a key is never recoverable, only replaced.
403
The key itself is valid, but the scope this endpoint needs is missing. The response identifies which requirement failed, because the fix differs: {"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.
404
The route or requested resource does not exist, or is deliberately not visible to this key. That covers a {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}.
429
Over quota. The JSON body names the meter: 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.
400
Valid key, bad input. JSON error explains the problem.
http
Authorization: Bearer ss_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
json · 429 body
{ "error": "monthly_credits_exhausted" }
GET/api/v1/status
scope · any keycost · free

Introspection. Confirm a key works and see its plan and scopes. Free, so it is safe for health checks and CI.

bash
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
  "https://stackscope.dev/api/v1/status"
json · response
{
  "keyPrefix": "ss_live_AZTQ",
  "plan": "pro",
  "scopes": ["lookup", "trends"],
  "status": "ok"
}
GET/api/v1/lookup
scope · lookupcost · 1 credit + 1 distinct launch

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}.

domainrequired
Bare host or full URL; resolved by host. A bare domain matches a launch on any path under it, preferring the homepage.
includeoptional
Comma list of enrichment blocks: 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_confidence
min_category_confidence
min_country_confidenceoptional
Confidence floors, 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.
bash
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
  "https://stackscope.dev/api/v1/lookup?domain=arcloop.ai&include=infra,quality"
json · response
{
  "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
}
json · domain not held
{ "found": false }
GET/api/v1/tech
scope · listscost · 1 credit pro and above

The public fingerprint catalogue, ranked by distinct launch count.

limitoptional
Default 100, max 500. The catalogue is ranked by launch count, so a limit of 500 gives you the 500 most-detected technologies. There is no paging past that.
from, tooptional
ISO dates narrowing the counts to launches in that window, so you can ask what was being adopted in a given month rather than ever. Either may be omitted: 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.
firstLaunchAtresponse field
The earliest launch we hold running this technology. Always lifetime, never windowed: it answers since when we hold it at all, which a date filter must not change.
Our record starts 2026-04-01. That is the earliest launch date we hold; the first crawls landed on 2026-04-04. A 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.
launchCount counts everything the list endpoints serve by default, which is the whole served corpus. indieLaunchCount counts only the rows our review classed as launches, matching what you get with indie=true. Both are given because filtering changes which one is the right denominator.
bash
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
  "https://stackscope.dev/api/v1/tech?limit=100"
json · response
[
  {
    "provider": "Tailwind CSS",
    "slug": "tailwindcss",
    "category": "css_framework",
    "layer": null,
    "launchCount": 117190,
    "indieLaunchCount": 55083,
    "firstLaunchAt": "2026-04-01T00:00:32.939+00:00"
  }
]
json · with from/to
{
  "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"
    }
  ]
}
GET/api/v1/tech/{slug}/launches
scope · listscost · 1 credit + 1 distinct per row pro and above

Launches using a tech, newest first. An unknown, internal, or inactive slug returns 404, so the internal namespace cannot be enumerated.

Not the endpoint to poll. This one offers 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.
{slug}required
A public fingerprint slug (path segment). The same slugs the Stackdar watch form autocompletes.
includeoptional
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.
sinceoptional
ISO date.
offset, limitoptional
Default offset 0, limit 50 (max 100).
min_confidence
min_category_confidence
min_country_confidenceoptional
Confidence floors, 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.
bash
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
  "https://stackscope.dev/api/v1/tech/nextjs/launches?since=2026-07-01&limit=50"
json · response
{
  "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
    }
  ]
}
GET/api/v1/launches
scope · feedcost · 1 credit + 1 distinct per row pro and above

The pull feed: new launches, filterable, newest first. Poll it on your own schedule, or let Stackdar push instead.

Polling? Use 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.
observed_since, observed_untiloptional
Bound the window on when the row entered our corpus. This is the one to poll on, and it switches the sort from launch date to arrival order, newest arrival first, so cursor walks backwards through arrivals from the top of the window. Inclusive lower bound, exclusive upper.
cursoroptional
Pass back the 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.
since, untiloptional
Bound the window on the launch date, for historical queries ("what shipped in April"). Inclusive lower bound, exclusive upper; bare dates are read as UTC. Not the parameter to poll on.
techoptional
Public slug; unknown or internal slugs 404.
launch_typeoptional
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.
indieoptional
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.
offset, limitoptional
Limit defaults to 50, max 100. Offset pages through the feed.
category, countryoptional
Country is ISO2 (e.g. US), gated at ≥ 0.45 confidence.
includeoptional
quality attaches banded quality (quintiles, 1 lowest to 5 highest), never precise scores.
min_confidence
min_category_confidence
min_country_confidenceoptional
Confidence floors, 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.
bash
# 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.
json · response
{
  "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
    }
  ]
}
GET/api/v1/export
scope · exportcost · 1 credit + 1 distinct per row

CSV of the feed or a tech's launches. Returns text/csv with a Content-Disposition filename.

typeoptional
launches (default) or tech-launches.
techoptional
Required for tech-launches; a filter for launches.
category, country, since, until,
observed_since, observed_until,
launch_type, indieoptional
All exactly as on /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.
limitoptional
Default 1000, max 5000. There is no cursor on this endpoint: narrow with a date window instead, which is also cheaper against your meters.
includeoptional
quality adds banded quality columns (quintiles, 1 lowest to 5 highest).
min_confidence
min_category_confidence
min_country_confidenceoptional
Confidence floors, 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.
Export generation is deterministic. For the same key and unchanged row data, repeated requests return byte-identical output, so you can diff two pulls or cache one and trust the comparison. The per-key marker each file carries, which the licence asks you to leave in place, is deterministic too and introduces no differences of its own. Fields that keep changing after collection can still make a later export differ: liveness_status is the one to watch, since liveness is re-checked on its own schedule long after the crawl.
bash
curl -H "Authorization: Bearer $STACKSCOPE_API_KEY" \
  "https://stackscope.dev/api/v1/export?tech=nextjs&since=2026-07-01" -o launches.csv
text · columns
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.

scope · watchescost · free
GET/api/v1/watches

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.

json · response
{
  "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
      }
    }
  ]
}
POST/api/v1/watches

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.

techsoptional
Array of public slugs. Omitting it makes the technology dimension a wildcard, not the whole watch: {"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.
techsAlloptional
Array of slugs the launch must all carry, where 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.
techsExcludingoptional
Array of slugs that disqualify a launch: {"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.
categories, countriesoptional
Arrays. A launch matches if it is any one of them. Leave a list out to match them all. Countries are ISO2.
sourceoptional
Narrow to a single discovery source, for example 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.
launchType, indieoptional
Same meanings as on /launches. On a directory watch indie does nothing, because everything arriving from a launch platform is already indie.
on successresponse
200 with the new watch, not 201, and no Location header. The body carries webhookSecret exactly once.
channeloptional
webhook (default), slack or email. The every-launch stream cannot be delivered by email.
webhookUrlrequired for webhook and slack
HTTPS, and it must resolve to a public address. Private, loopback and reserved ranges are refused here, and every delivery re-resolves the host and pins its connection to a checked public address.
digestCadenceoptional
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.
digestTimezone
digestHour
digestDayOfWeek
digestCsvoptional
Exact types, since these are easy to guess wrong: 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.
webhookSecret is returned once. It appears only on this response, only for the 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.
bash
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"
json · response
{
  "id": 42,
  "channel": "webhook",
  "digestCadence": "instant",
  "isActive": true,
  "webhookSecret": "shown once, never again"
}
DELETE/api/v1/watches/{id}

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.

json · response
{ "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

credits
One per request, regardless of how many rows it returns. Both meters reset at the start of your billing period, the monthly anniversary of the day you subscribed, not on the first of the calendar month. When the period total across all your keys would exceed the plan cap, the request returns 429 monthly_credits_exhausted.
distinct launches
Each new launch touched in a billing period is recorded once; repeat touches are free for the rest of that period. A new launch over the ceiling returns 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.
how much is left
Every metered response carries 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.
rate
Separate from the meters and per key: roughly 5 requests a second, absorbing bursts of up to 50. It sits far above any plan's monthly budget and exists to stop a runaway loop or a stolen key, so ordinary use will not meet it. Exceeding it returns 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.
at the boundary
Requests are never truncated. The two meters stop you differently. The credit cap is hard: with no credit left the whole request is refused before any work, and costs nothing. The distinct-launch ceiling is checked at the start of a request: if the account is still below it the request completes in full, even where its rows take usage past the ceiling, so a 1,000-row export with 500 remaining returns all 1,000. The next request that would touch a previously unseen launch is then refused. Rows already touched that month stay available after the ceiling is reached.

Plans

PlanEndpointsMonthly creditsDistinct-launch ceilingWatchesTechs 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.

text · scope map
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

webhook
An HTTPS POST per matching launch, near-real-time. Signed.
slack
An HTTPS POST to a Slack incoming-webhook URL. The URL is the secret, so no signature.
email
A batched digest to your account email, 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

at-least-once
A crash between delivery and our bookkeeping re-delivers; dedupe on X-StackScope-Delivery.
retries
A non-2xx or transport error retries with exponential backoff. Five attempts in total (the first delivery plus four retries), then the event is abandoned. Repeated failures across events disable the watch.
auto-disable
After 20 consecutive failures a watch is disabled so a dead endpoint stops consuming events. There is no re-enable: delete it and create a replacement, which also mints a new signing secret.
takedowns
The payload is assembled at delivery time, so a launch hidden after the event was queued is dropped, not delivered.
http · webhook 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. */ }
}
javascript · verify signature
// 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);