>5B subdomains. curl, done.

One endpoint. Plain-text output, newline-delimited. No SDK, no schema, no JSON wrapping. Built for shell pipelines and the kind of engineer who reads response bodies before reading docs.

curl https://api.sub.md/v1/search?apex=example.com

Returns 200 with one FQDN per line. 404 if the apex has no indexed subdomains. 429 when you've hit the anonymous rate limit; respect Retry-After or grab a token at /pricing.

>-------------------------------------------------------------------<

Try it

Hit the live API straight from this page. Optional Bearer paste if you want to test your own token. Press / to focus the input.

+ use Bearer token

                
Tip: anonymous gets you 50 queries / day from this IP. Use a token for headroom.
>-------------------------------------------------------------------<

Authentication

Opaque bearer tokens. No accounts, no logins, no portal. The token is the identity.

Sending a token

curl -H "Authorization: Bearer submd_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  https://api.sub.md/v1/search?apex=example.com

Token format

Tokens are prefixed submd_ followed by URL-safe base32. Treat them as bearer secrets - anything that holds the token can spend the quota. Share across machines is fine; the quota is per token, not per IP.

Got a 401?

  • Header missing Bearer prefix (note the space)
  • Token expired - entitlements are 30/90/180/365-day windows from purchase, not rolling
  • Token revoked (we'll only do this in response to abuse; you'd know)
  • Stripped by an upstream proxy - confirm with curl -v

Lost your token?

We don't store the plaintext. The /payment/complete page shows your token once right after purchase, then never again. If that window closed, email [email protected] with your payment reference and we'll reissue.

>-------------------------------------------------------------------<

Endpoints

One real endpoint, plus health probes. That's it.

Method Path Auth Returns
GET /v1/search?apex=<domain> Optional Bearer (anon limited) text/plain, one FQDN per line
GET /healthz None ok\n
GET /readyz None ready\n
OPTIONS any of the above None 204 with Allow: header

The apex parameter

Pass the registrable domain, not a subdomain. We split using the Public Suffix List with private suffixes ignored (so foo.blogspot.com splits as apex blogspot.com). If you pass a full FQDN we'll try to extract the apex, but be explicit and you'll get fewer 400s.

>-------------------------------------------------------------------<

Response headers

Every header below is set by sub.md (not your CDN). When something looks off, these tell you why.

X-Auth-Type
token or anon - which limiter bucket served you.
Action: if you sent a Bearer header but see anon, your token didn't validate. Re-check the header format.
X-Cache
HIT = served from the response cache. MISS = freshly computed.
Action: HIT responses ship faster and won't reflect data refreshed in the last 5 min. ETag tells you whether it actually changed.
ETag
Strong validator for cached responses. Only present on cache HITs (anon traffic).
Action: send If-None-Match on subsequent requests to get a free 304 Not Modified instead of paying for bytes.
Vary
Accept-Encoding, Authorization
Action: if you cache responses client-side, key on the request's Accept-Encoding and Authorization too. We do.
Cache-Control
Anonymous: short-lived public cache with stale-while-revalidate. Token: private, no-store.
Action: a downstream CDN/proxy can safely cache anon responses. Token responses must not be cached publicly.
Retry-After
Seconds to wait before retrying. Set on 429 and on 503 when the backend is under pressure.
Action: respect this. Hammering through a 429 is the fastest way to a longer cooldown. Use exponential backoff with jitter.
WWW-Authenticate
Bearer realm="sub.md" on 401.
Action: standard RFC 6750 challenge. Your HTTP client probably knows what to do with it.
Content-Encoding: gzip
Set when you sent Accept-Encoding: gzip and the response is large.
Action: always send Accept-Encoding: gzip for big result sets - typical 4-6x reduction.
>-------------------------------------------------------------------<

Status codes

What you'll see, when, and what to do.

200OK

Found one or more subdomains for the apex.

admin.example.com
api.example.com
www.example.com
...

304Not Modified

Your If-None-Match matched the cached ETag. Body is empty; reuse what you have.

400Bad Request

Missing or malformed apex. The body tells you which.

missing ?apex= parameter

401Unauthorized

Bearer header was present but didn't validate. Header format wrong, token revoked, or expired.

invalid bearer token

404Not Found

Apex parsed fine, we just don't know it. Try the registrable domain (cut subdomains).

no results for example.tld

405Method Not Allowed

You sent something other than GET/HEAD/OPTIONS. Allow: header lists what works.

429Too Many Requests

Anonymous: 50 queries/day or 1 req/s burst exceeded. Token: monthly quota or per-second burst exceeded. Retry-After is set.

Rate limit exceeded. Upgrade at https://sub.md/pricing

503Service Unavailable

Backend pressure (DB lookup queue full or auth backend hiccup). Retry-After is set, usually 2 s. Transient - retry with backoff.

>-------------------------------------------------------------------<

Rate limits

Per-tier hard limits. Burst is a token-bucket size; sustained is the leak rate.

Tier req/s sustained burst window quota
Anonymous / Community 1 2 50 / day
Researcher 5 10 5,000 / month
Professional 20 30 100,000 / month
Architect 100 150 2,000,000 / month
Firehose 1,000 1,500 100,000,000 / month

Anonymous limits are per-source with subnet aggregation, so NAT'd offices share a budget. Token limits are per-token regardless of source IP.

>-------------------------------------------------------------------<

Caching

Anonymous responses carry an ETag and a short public cache lifetime so downstream proxies can serve repeat hits without round-tripping. Token responses are private, no-store: your bearer key never sits in a shared cache. Use If-None-Match on follow-up requests for a free 304.

ETag round-trip

# first request: capture the ETag
curl -i https://api.sub.md/v1/search?apex=example.com
HTTP/1.1 200 OK
ETag: "8d4f2a..."

# subsequent request: short-circuit if unchanged
curl -i -H 'If-None-Match: "8d4f2a..."' \
  https://api.sub.md/v1/search?apex=example.com
HTTP/1.1 304 Not Modified
>-------------------------------------------------------------------<

Code samples

Each tab is a complete program that handles 429 with a single retry. Copy, drop into a file, run.

#!/usr/bin/env bash
# Usage: ./submd.sh example.com
set -euo pipefail
APEX="$1"
TOKEN="${SUBMD_TOKEN:-}"
AUTH=()
[[ -n "$TOKEN" ]] && AUTH=(-H "Authorization: Bearer $TOKEN")

for attempt in 1 2; do
  curl -fsS --compressed "${AUTH[@]}" \
    "https://api.sub.md/v1/search?apex=$APEX" && exit 0
  sleep 2
done
echo "failed after retry" >&2
exit 1
# pip install requests
import os, time, requests

def search(apex, token=None):
    headers = {"Accept-Encoding": "gzip"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    for _ in range(2):
        r = requests.get(
            "https://api.sub.md/v1/search",
            params={"apex": apex},
            headers=headers,
            timeout=10,
        )
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", 2)))
            continue
        r.raise_for_status()
        return r.text.splitlines()
    raise RuntimeError("rate-limited twice")

if __name__ == "__main__":
    import sys
    print("\n".join(search(sys.argv[1], os.getenv("SUBMD_TOKEN"))))
package main

import (
    "bufio"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

func search(apex, token string) ([]string, error) {
    u := "https://api.sub.md/v1/search?apex=" + url.QueryEscape(apex)
    client := &http.Client{Timeout: 10 * time.Second}
    for i := 0; i < 2; i++ {
        req, _ := http.NewRequest("GET", u, nil)
        req.Header.Set("Accept-Encoding", "gzip")
        if token != "" {
            req.Header.Set("Authorization", "Bearer "+token)
        }
        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()
        if resp.StatusCode == 429 {
            d, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
            time.Sleep(time.Duration(d) * time.Second)
            continue
        }
        b, _ := io.ReadAll(resp.Body)
        var out []string
        s := bufio.NewScanner(bufio.NewReader(strings.NewReader(string(b))))
        for s.Scan() { out = append(out, s.Text()) }
        return out, nil
    }
    return nil, fmt.Errorf("rate-limited twice")
}

func main() {
    subs, err := search(os.Args[1], os.Getenv("SUBMD_TOKEN"))
    if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
    for _, s := range subs { fmt.Println(s) }
}
// Node 18+ (built-in fetch)
async function search(apex, token) {
  const headers = { "Accept-Encoding": "gzip" };
  if (token) headers.Authorization = `Bearer ${token}`;
  const url = `https://api.sub.md/v1/search?apex=${encodeURIComponent(apex)}`;
  for (let i = 0; i < 2; i++) {
    const r = await fetch(url, { headers });
    if (r.status === 429) {
      await new Promise(res => setTimeout(res, parseInt(r.headers.get("Retry-After") || "2") * 1000));
      continue;
    }
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    return (await r.text()).split("\n").filter(Boolean);
  }
  throw new Error("rate-limited twice");
}

search(process.argv[2], process.env.SUBMD_TOKEN)
  .then(subs => subs.forEach(s => console.log(s)))
  .catch(e => { console.error(e.message); process.exit(1); });
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "gzip"] }
use std::{env, thread, time::Duration};

fn search(apex: &str, token: Option<&str>) -> Result<Vec<String>, reqwest::Error> {
    let client = reqwest::blocking::Client::builder().gzip(true).build()?;
    let url = format!("https://api.sub.md/v1/search?apex={}", apex);
    for _ in 0..2 {
        let mut req = client.get(&url);
        if let Some(t) = token { req = req.bearer_auth(t); }
        let r = req.send()?;
        if r.status() == 429 {
            let d: u64 = r.headers().get("retry-after")
                .and_then(|v| v.to_str().ok()).and_then(|s| s.parse().ok()).unwrap_or(2);
            thread::sleep(Duration::from_secs(d));
            continue;
        }
        return Ok(r.error_for_status()?.text()?.lines().map(String::from).collect());
    }
    panic!("rate-limited twice");
}

fn main() {
    let apex = env::args().nth(1).expect("apex required");
    let tok = env::var("SUBMD_TOKEN").ok();
    for s in search(&apex, tok.as_deref()).unwrap() { println!("{}", s); }
}
# PowerShell 5.1+ / 7.x
function Get-SubmdSubdomains {
    param([string]$Apex, [string]$Token = $env:SUBMD_TOKEN)
    $headers = @{ "Accept-Encoding" = "gzip" }
    if ($Token) { $headers["Authorization"] = "Bearer $Token" }
    for ($i = 0; $i -lt 2; $i++) {
        try {
            $r = Invoke-WebRequest -Uri "https://api.sub.md/v1/search?apex=$Apex" `
                -Headers $headers -UseBasicParsing
            return $r.Content -split "`n" | Where-Object { $_ }
        } catch {
            if ($_.Exception.Response.StatusCode -eq 429) {
                $d = [int]($_.Exception.Response.Headers["Retry-After"] -as [string])
                if (-not $d) { $d = 2 }
                Start-Sleep -Seconds $d
                continue
            }
            throw
        }
    }
    throw "rate-limited twice"
}

Get-SubmdSubdomains -Apex $args[0]
>-------------------------------------------------------------------<

Real-world recipes

sub.md replaces the slow, noisy, often-blocked subdomain enumeration step. What you do with the names afterwards is where the work begins. Drop these into your bb / red team / continuous-monitoring loop.

Live host probe + tech fingerprint

Pipe sub.md into dnsx to keep only resolvers, then httpx for live HTTP/S probing with TLS, status, title and tech detection. The full ProjectDiscovery probe stack on a fresh apex in two pipes.

curl -s https://api.sub.md/v1/search?apex=example.com \
  | dnsx -silent -a -resp \
  | awk '{print $1}' \
  | httpx -silent -title -tech-detect -status-code -tls-grab \
       -threads 50 -rate-limit 100 \
  | tee live-"$(date +%F)".txt

Bug-bounty pipeline: sub.md to nuclei to subzy

Full passive-to-active hop. sub.md feeds the apex's full subdomain surface, dnsx filters for resolvers, httpx finds live HTTP, nuclei runs the medium+ template set, subzy sweeps for dangling-CNAME takeovers in parallel. Output is two clean text files ready for triage.

APEX="$1"
OUT="./recon-$APEX-$(date +%F)" && mkdir -p "$OUT"

curl -s -H "Authorization: Bearer $SUBMD_TOKEN" \
     "https://api.sub.md/v1/search?apex=$APEX" \
  | dnsx -silent -a \
  | tee "$OUT/resolved.txt" \
  | httpx -silent -mc 200,201,301,302,401,403 \
       -title -tech-detect -tls-grab \
       -o "$OUT/live.txt"

awk '{print $1}' "$OUT/live.txt" \
  | nuclei -silent -severity medium,high,critical \
            -tags cve,exposure,misconfig,takeover \
            -rl 75 -c 25 -o "$OUT/nuclei.txt" &

subzy run --targets "$OUT/resolved.txt" \
            --hide_fails --vuln \
  > "$OUT/takeovers.txt" &

wait

Historical URLs and parameter discovery

Combine sub.md's name surface with gau (Wayback / Common Crawl / OTX), hakrawler for live JS-aware crawling, subjs to extract every JS file URL the live hosts ship, gf for known-pattern grep (xss, ssrf, redirect, sqli) and qsreplace to fuzz parameter values against a nuclei or custom probe. Catches the soft targets the live-only scanners miss.

curl -s "https://api.sub.md/v1/search?apex=$1" \
  | httpx -silent \
  | tee /tmp/live.txt \
  | hakrawler -d 2 -u -subs > /tmp/crawl.txt &

cat /tmp/live.txt | gau --threads 10 --subs > /tmp/wayback.txt &
cat /tmp/live.txt | subjs -c 25 > /tmp/jsfiles.txt &
wait

cat /tmp/crawl.txt /tmp/wayback.txt /tmp/jsfiles.txt \
  | gf xss \
  | qsreplace '"><svg/onload=confirm(1)>' \
  | nuclei -silent -t fuzzing-templates/xss/ -o /tmp/xss-hits.txt

Continuous monitoring with anew + notify

Cron this hourly. anew (Tomnomnom) prints only lines not already in the keep-file, so each run yields just the deltas. Pipe through notify (ProjectDiscovery) for Slack / Discord / Telegram / pushover / email fan-out from one config.

APEX="$1"; KEEP="$HOME/.submd/$APEX.txt"
mkdir -p "$(dirname "$KEEP")"

curl -s -H "Authorization: Bearer $SUBMD_TOKEN" \
     "https://api.sub.md/v1/search?apex=$APEX" \
  | anew "$KEEP" \
  | httpx -silent -title -tech-detect -status-code \
  | notify -bulk -id slack -silent
>-------------------------------------------------------------------<