>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.
>-------------------------------------------------------------------<
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
Bearerprefix (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.
token or anon - which limiter bucket served you.anon, your token didn't validate. Re-check the header format.HIT = served from the response cache. MISS = freshly computed.If-None-Match on subsequent requests to get a free 304 Not Modified instead of paying for bytes.Accept-Encoding, Authorizationprivate, no-store.429 and on 503 when the backend is under pressure.Bearer realm="sub.md" on 401.Accept-Encoding: gzip and the response is large.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
>-------------------------------------------------------------------<