API Platform
Domain Research API
See which keywords any domain ranks for, who its organic competitors are, the keyword gap against a competitor, which pages and subdomains earn its traffic, its ranking history, and traffic estimates for up to 1000 domains at once.
See which keywords any domain ranks for, who its organic competitors are, the keywords a competitor ranks for and you do not, which pages and subdomains earn its traffic, how its visibility has moved month by month, and traffic estimates for up to 1000 domains in one call. One endpoint serves eight reports, selected with the type parameter. Turkish data covers more than 61 million keywords.
Requests longer than 60 seconds
This endpoint runs requests as tasks. A request that runs longer than 60 seconds returns 202 with a task_id; collect the result from GET /v1/tasks/{task_id}. Every response, including a normal 200, carries the task id in the X-Task-Id header. Use ?wait= to change how long the request waits, and an Idempotency-Key header to retry safely.
Authentication
All requests require header-based authentication. Include your API credentials in every request.
| Header | Type | Description |
|---|---|---|
SEMUST-API-USER | string | Your API user's username |
SEMUST-API-PASSWORD | string | Your API user's password |
Accept-Language | string | Optional. Send tr to receive error messages in Turkish |
Create API credentials from the API Access page.
SEMUST-API-USER: your_username
SEMUST-API-PASSWORD: your_password
Content-Type: application/jsonReport Types
type | Returns | Billed rows |
|---|---|---|
overview | One summary: keyword count, estimated traffic and its value, how many keywords sit in the top 3 / 10 / 20 / 100, new and lost keywords | 1 |
keywords | The keywords the domain ranks for, with volume, CPC, difficulty, intent, position, ranking URL and estimated traffic | rows returned |
competitors | Domains competing for the same keywords, ranked by how many they share. The domain itself is never listed | rows returned |
top_pages | The domain's pages ranked by the organic traffic they earn | rows returned |
keyword_gap | Keywords compared against a competitor: the ones it ranks for and you do not (default), the ones you share, or the ones only you rank for | rows returned |
subdomains | The domain's subdomains ranked by the organic traffic they earn | rows returned |
traffic_estimate | Estimated organic and paid traffic for a list of up to 1000 domains in one call | one per domain sent |
history | The overview numbers month by month, up to 60 months back | one per month returned, on the history price |
overview is the default. It returns a single row, so a request without a type costs the minimum.
{
"type": "keywords",
"domain": "example.com.tr",
"limit": 100
}Request Parameters
Send an application/json body. A parameter that does not apply to the chosen type is rejected rather than ignored, and a misspelled one is named in the error with a suggestion, so a typo never silently returns something other than what you asked for. A JSON null is treated as "not sent".
Body
domainstringrequiredThe domain to research. https://, www., a port, any path and the Search Console sc-domain: prefix are removed for you. Internationalised names (örnek.com.tr) are accepted. Every type except traffic_estimate, which takes domains.
typestringdefault: overviewoverview, keywords, competitors, top_pages, keyword_gap, subdomains, traffic_estimate or history.
competitorstringkeyword_gap only, and required there. The domain to compare against, cleaned up the same way as domain. It cannot be the same domain.
modestringdefault: missingkeyword_gap only. missing: keywords the competitor ranks for and you do not — your content gap. shared: keywords you both rank for, with both positions. unique: keywords only you rank for.
domainsstring[]traffic_estimate only, and required there. 1–1000 domains, cleaned up like domain. Duplicates are merged, and rows come back in the order you sent them. Each domain is billed as a row, including one we have no data for.
monthsintegerdefault: 12history only. How many whole months back from the current one, 1–60. The current month is not included until it is complete.
countrystringdefault: TRTwo-letter country code of the search market. See Markets below.
languagestringLanguage of the market. Defaults to the market's main language (tr for TR).
limitintegerdefault: 100Rows to return, 1–1000. Paged reports only: keywords, competitors, top_pages, keyword_gap, subdomains.
pageintegerdefault: 1Page number. page × limit can be at most 10,000. Paged reports only.
sortstringkeywords: traffic (default), volume, position, difficulty, cpc. competitors: common_keywords (default), avg_position. top_pages: traffic. keyword_gap: volume (default), difficulty, cpc. subdomains: traffic (default), keywords.
directionstringdesc or asc. Defaults to asc for position and avg_position (best first) and desc for everything else.
min_volumeintegerkeywords and keyword_gap. Keep keywords with at least this monthly search volume.
max_positionintegerkeywords only. Keep keywords where the domain ranks at this position or better, 1–100.
intentstringkeywords and keyword_gap. informational, navigational, commercial or transactional.
containsstringkeywords and keyword_gap. Keep keywords containing this text, 1–80 characters, case-insensitive (Turkish İ/ı handled correctly).
idempotency_keystringOptional, up to 128 characters. Repeating a request with the same key returns the same task — its 202 while it runs, its stored result once it finishes — and it is charged once, so a timed-out request can be retried safely. The key is reserved until 72 hours after the task finishes. Same as the Idempotency-Key header; if you send both, this field wins.
Query
waitintegerdefault: 60Seconds to wait for the result, 0–80. If the request is still running after that, the response is 202 with a task_id. 0 answers 202 at once.
Headers
Idempotency-KeystringOptional. Works like idempotency_key above; if you send both, the body field wins.
{
"type": "keywords",
"domain": "example.com.tr",
"limit": 50,
"min_volume": 100,
"max_position": 10,
"intent": "commercial",
"contains": "avukat",
"sort": "volume"
}{
"type": "keyword_gap",
"domain": "example.com.tr",
"competitor": "rival.com.tr",
"mode": "missing",
"min_volume": 500,
"limit": 100
}{
"type": "traffic_estimate",
"domains": ["example.com.tr", "rival.com.tr", "another.com"]
}{
"type": "history",
"domain": "example.com.tr",
"months": 24
}Markets
GET /v1/domain-research/markets lists every supported country and its languages. It makes no data lookup and is free.
{
"success": true,
"default_country": "TR",
"markets": [
{ "country": "TR", "name": "Turkiye", "languages": ["tr"] },
{ "country": "US", "name": "United States", "languages": ["en", "es"] }
]
}Code Examples
Fetching a domain's top keywords.
A request still running after 60 seconds returns 202. The call() helper on Tasks & Long-Running Requests polls until the result is ready.
curl -X POST https://data.semust.com/v1/domain-research \
-H "SEMUST-API-USER: your_username" \
-H "SEMUST-API-PASSWORD: your_password" \
-H "Content-Type: application/json" \
-d '{
"type": "keywords",
"domain": "example.com.tr",
"limit": 100
}'import requests
response = requests.post(
"https://data.semust.com/v1/domain-research",
headers={
"SEMUST-API-USER": "your_username",
"SEMUST-API-PASSWORD": "your_password",
},
json={"type": "keywords", "domain": "example.com.tr", "limit": 100},
timeout=90, # longer than ?wait= (60 seconds by default)
)
data = response.json()
if response.status_code == 202:
# Still running. Collect it later from data["poll_url"],
# or use the call() helper from the Tasks page.
raise SystemExit(f"Task {data['task_id']} is still running")
if response.status_code != 200:
# Every error carries a stable `code`; `retryable` says whether trying again can help.
raise SystemExit(f"{data['code']}: {data['error']} {data.get('hint', '')}")
print(f"{data['count']} of {data['total']} keywords, cost ${data['cost']:.6f}")
for row in data["items"]:
print(row["position"], row["keyword"], row["volume"], row["traffic"])const response = await fetch("https://data.semust.com/v1/domain-research", {
method: "POST",
headers: {
"SEMUST-API-USER": "your_username",
"SEMUST-API-PASSWORD": "your_password",
"Content-Type": "application/json",
},
body: JSON.stringify({ type: "keywords", domain: "example.com.tr", limit: 100 }),
});
const data = await response.json();
if (response.status === 202) {
// Still running. Collect it later from data.poll_url,
// or use the call() helper from the Tasks page.
throw new Error(`Task ${data.task_id} is still running`);
}
if (!response.ok) {
throw new Error(`${data.code}: ${data.error}`);
}
console.log(`${data.count} of ${data.total} keywords, cost $${data.cost}`);Response
Every report except overview returns rows in items; overview returns a single data object instead. total is how many rows exist for your query, count how many are in this response. traffic_estimate and history are not paged: they return everything at once, with no page, limit or sort.
Position counts in overview are cumulative: top10 includes top3. Traffic is an estimate of monthly organic visits. A value we do not have is null, never 0 — "cpc": null means no CPC data, not a CPC of zero.
The examples below are real responses for semust.com in Turkey.
{
"success": true,
"type": "overview",
"domain": "semust.com",
"country": "TR",
"language": "tr",
"data": {
"organic": {
"keywords": 136,
"traffic": 1233,
"traffic_value_usd": 1541.15,
"top3": 6,
"top10": 36,
"top20": 89,
"top100": 136,
"new": 86,
"lost": 29,
"improved": 25,
"declined": 23
},
"paid": { "keywords": 0, "traffic": 0, "traffic_value_usd": 0 }
},
"cost": 0.013332,
"cached": false,
"request_id": "3f1c2a9e-5b7d-4c1e-9a2f-8d6e4b0c7a11"
}{
"success": true,
"type": "keywords",
"domain": "semust.com",
"country": "TR",
"language": "tr",
"total": 136,
"count": 3,
"page": 1,
"limit": 3,
"sort": "traffic",
"direction": "desc",
"items": [
{
"keyword": "google search console",
"volume": 60500,
"cpc": 1.57,
"difficulty": 85,
"intent": "navigational",
"position": 19,
"url": "https://semust.com/blog/google-search-console-nedir",
"traffic": 373
},
{
"keyword": "semust",
"volume": 390,
"cpc": 0.31,
"difficulty": 4,
"intent": "informational",
"position": 1,
"url": "https://semust.com/",
"traffic": 154
},
{
"keyword": "18 arama motoru isimleri",
"volume": 320,
"cpc": null,
"difficulty": 0,
"intent": "informational",
"position": 4,
"url": "https://semust.com/blog/en-iyi-arama-motorlari",
"traffic": 19
}
],
"cost": 0.013596,
"cached": false,
"request_id": "9b0e7c4d-2a1f-4e3b-8c5d-6f7a8b9c0d1e"
}{
"success": true,
"type": "competitors",
"domain": "semust.com",
"country": "TR",
"language": "tr",
"total": 2630,
"count": 2,
"page": 1,
"limit": 2,
"sort": "common_keywords",
"direction": "desc",
"items": [
{
"domain": "eksisozluk.com",
"common_keywords": 69,
"avg_position": 10.2,
"keywords": 795972,
"traffic": 33062395
},
{
"domain": "webtekno.com",
"common_keywords": 59,
"avg_position": 19.9,
"keywords": 59032,
"traffic": 977179
}
],
"cost": 0.013464,
"cached": false,
"request_id": "c2d4e6f8-1a3b-4c5d-8e9f-0a1b2c3d4e5f"
}{
"success": true,
"type": "top_pages",
"domain": "semust.com",
"country": "TR",
"language": "tr",
"total": 40,
"count": 2,
"page": 1,
"limit": 2,
"sort": "traffic",
"direction": "desc",
"items": [
{
"url": "https://semust.com/blog/google-search-console-nedir",
"keywords": 9,
"traffic": 594,
"top3": 0,
"top10": 3
},
{
"url": "https://semust.com/blog/yapay-zeka-fotograf-promptlari",
"keywords": 26,
"traffic": 218,
"top3": 2,
"top10": 17
}
],
"cost": 0.013464,
"cached": false,
"request_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9"
}{
"success": true,
"type": "keyword_gap",
"domain": "semust.com",
"competitor": "magnadijital.com.tr",
"mode": "missing",
"country": "TR",
"language": "tr",
"total": 228,
"count": 2,
"page": 1,
"limit": 2,
"sort": "volume",
"direction": "desc",
"items": [
{
"keyword": "video indirme",
"volume": 450000,
"cpc": 0.15,
"difficulty": 15,
"intent": "transactional",
"position": null,
"url": null,
"competitor_position": 38,
"competitor_url": "https://www.magnadijital.com.tr/blog/sosyal-medya-yonetimi/internetten-video-indirmek-icin-kapsamli-bir-kilavuz"
},
{
"keyword": "duck duck",
"volume": 135000,
"cpc": 0.46,
"difficulty": 92,
"intent": "informational",
"position": null,
"url": null,
"competitor_position": 27,
"competitor_url": "https://www.magnadijital.com.tr/blog/seo/duckduckgo-nedir"
}
],
"cost": 0.013464,
"cached": false,
"request_id": "0d9c8b7a-6f5e-4d3c-9b2a-1f0e9d8c7b6a"
}{
"success": true,
"type": "subdomains",
"domain": "semust.com",
"country": "TR",
"language": "tr",
"total": 1,
"count": 1,
"page": 1,
"limit": 10,
"sort": "traffic",
"direction": "desc",
"items": [
{
"subdomain": "semust.com",
"keywords": 136,
"traffic": 1233,
"top3": 6,
"top10": 36
}
],
"cost": 0.013332,
"cached": false,
"request_id": "4a5b6c7d-8e9f-4a0b-9c1d-2e3f4a5b6c7d"
}{
"success": true,
"type": "traffic_estimate",
"country": "TR",
"language": "tr",
"total": 3,
"count": 3,
"items": [
{
"domain": "semust.com",
"keywords": 136,
"traffic": 1233,
"paid_keywords": 0,
"paid_traffic": 0
},
{
"domain": "magnadijital.com.tr",
"keywords": 271,
"traffic": 2433,
"paid_keywords": 0,
"paid_traffic": 0
},
{
"domain": "no-data-example.com",
"keywords": 0,
"traffic": 0,
"paid_keywords": 0,
"paid_traffic": 0
}
],
"cost": 0.013596,
"cached": false,
"request_id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e"
}{
"success": true,
"type": "history",
"domain": "semust.com",
"country": "TR",
"language": "tr",
"months": 3,
"total": 3,
"count": 3,
"items": [
{
"month": "2026-08",
"organic": {
"keywords": 139,
"traffic": 1174,
"traffic_value_usd": 890.9,
"top3": 7,
"top10": 43,
"top20": 81,
"top100": 139,
"new": 102,
"lost": 20,
"improved": 20,
"declined": 16
},
"paid": {
"keywords": 0,
"traffic": 0,
"traffic_value_usd": 0.0
}
},
{
"month": "2026-09",
"organic": {
"keywords": 162,
"traffic": 1870,
"traffic_value_usd": 1881.24,
"top3": 8,
"top10": 53,
"top20": 107,
"top100": 162,
"new": 117,
"lost": 13,
"improved": 24,
"declined": 20
},
"paid": {
"keywords": 0,
"traffic": 0,
"traffic_value_usd": 0.0
}
},
{
"month": "2026-10",
"organic": {
"keywords": 190,
"traffic": 1624,
"traffic_value_usd": 1770.1,
"top3": 8,
"top10": 58,
"top20": 124,
"top100": 190,
"new": 129,
"lost": 31,
"improved": 29,
"declined": 30
},
"paid": {
"keywords": 0,
"traffic": 0,
"traffic_value_usd": 0.0
}
}
],
"cost": 0.13596,
"cached": false,
"request_id": "e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"
}Response Fields Reference
Envelope
| Field | Type | Description |
|---|---|---|
success | boolean | true when the request succeeded |
type, domain, country, language | string | What was actually queried, after normalisation and defaults. traffic_estimate has no domain: each row names its own |
competitor, mode | string | The comparison applied (keyword_gap) |
months | integer | The months asked for (history) |
total | integer | Rows that exist for the query (list reports) |
count | integer | Rows in this response (list reports) |
page, limit, sort, direction | The paging and order applied (paged reports) | |
items | array | The rows (list reports) |
data | object | The summary (overview) |
cost | number | What this request cost, in USD |
cached | boolean | true when served from cache |
request_id | string | The task id. GET /v1/tasks/{request_id} returns this response again for 72 hours. Quote it to support to trace the request |
overview → data.organic
| Field | Type | Description |
|---|---|---|
keywords | integer | Keywords the domain ranks for in the top 100 |
traffic | integer | Estimated monthly organic visits |
traffic_value_usd | number | What that traffic would cost as paid search, in USD |
top3, top10, top20, top100 | integer | Keywords ranking at or above that position (cumulative) |
new, lost | integer | Keywords that entered or left the rankings since the previous update |
improved, declined | integer | Keywords whose position went up or down |
data.paid has keywords, traffic and traffic_value_usd for paid search.
keywords row
| Field | Type | Description |
|---|---|---|
keyword | string | The search query |
volume | integer | null | Average monthly searches |
cpc | number | null | Average cost per click, USD |
difficulty | integer | null | Ranking difficulty, 0–100 |
intent | string | null | informational, navigational, commercial or transactional |
position | integer | null | The domain's position |
url | string | null | The page that ranks |
traffic | integer | null | Estimated monthly visits from this keyword |
competitors row
| Field | Type | Description |
|---|---|---|
domain | string | The competing domain |
common_keywords | integer | Keywords both domains rank for |
avg_position | number | null | The competitor's average position on those shared keywords |
keywords | integer | null | Everything the competitor ranks for |
traffic | integer | null | The competitor's total estimated monthly traffic |
top_pages row
| Field | Type | Description |
|---|---|---|
url | string | The page |
keywords | integer | Keywords the page ranks for |
traffic | integer | Estimated monthly visits to the page |
top3, top10 | integer | Of those keywords, how many rank in the top 3 / top 10 |
keyword_gap row
| Field | Type | Description |
|---|---|---|
keyword | string | The search query |
volume, cpc, difficulty, intent | As in the keywords row | |
position, url | integer | null, string | null | Your domain's position and ranking page. Always null in mode: missing |
competitor_position, competitor_url | integer | null, string | null | The competitor's. Always null in mode: unique |
subdomains row
| Field | Type | Description |
|---|---|---|
subdomain | string | The subdomain, e.g. blog.example.com (the bare domain appears as itself) |
keywords | integer | Keywords it ranks for |
traffic | integer | Estimated monthly organic visits |
top3, top10 | integer | Of those keywords, how many rank in the top 3 / top 10 |
traffic_estimate row
| Field | Type | Description |
|---|---|---|
domain | string | The domain, as you sent it after clean-up |
keywords, traffic | integer | Organic keywords and estimated monthly organic visits |
paid_keywords, paid_traffic | integer | The same for paid search |
A domain we have no data for gets a row of zeros, in its place in the list.
history row
| Field | Type | Description |
|---|---|---|
month | string | YYYY-MM. Rows are oldest first |
organic, paid | object | The same fields as overview's data.organic and data.paid, for that month. new, lost, improved and declined compare with the month before |
Errors
Every error has the same shape: a stable code to branch on, a human-readable error, and retryable, which says whether sending the same request again can succeed. Where it helps, the error also names the field at fault, a hint on how to fix it, and the allowed values.
Errors in the 4xx range are found before anything is charged, so they cost nothing. A failure after that point is refunded in full. You are never charged for an error.
Checks run in a fixed order and the first problem is reported: JSON → unknown parameters → type → parameters the type does not take → domain / domains / competitor → market → values.
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 202 | — | Not an error: still running after wait seconds | Poll GET /v1/tasks/{task_id}; see Tasks |
| 400 | INVALID_JSON | The body is not a JSON object | Send a JSON object |
| 400 | UNKNOWN_PARAMETER | A parameter we do not know; hint suggests the closest match | Fix the spelling |
| 400 | INVALID_TYPE | Unknown type; allowed lists the valid ones | Use a value from allowed |
| 400 | PARAMETER_NOT_ALLOWED | The parameter exists but not for this type; hint names the types that take it | Remove it or change type |
| 400 | DOMAIN_REQUIRED | domain is missing | Send a domain |
| 400 | COMPETITOR_REQUIRED | keyword_gap without a competitor | Send the domain to compare against |
| 400 | INVALID_DOMAIN | Not a domain (an IP, localhost, no TLD…). field says which: domain, competitor or domains | Send a registered domain |
| 400 | UNSUPPORTED_MARKET | Country not supported, or language not available there; allowed lists that country's languages | See Markets |
| 400 | INVALID_PARAMETER | A value is out of range or of the wrong type; field names it | See the parameter's rules |
| 400 | PAGE_OUT_OF_RANGE | page × limit is over 10,000 | Narrow the query with filters |
| 401 | INVALID_CREDENTIALS | Username or password is wrong | Check your credentials |
| 402 | INSUFFICIENT_CREDITS | Your balance does not cover the request; required and balance say by how much | Lower limit or add funds |
| 403 | SERVICE_NOT_ENABLED | Domain Research is not enabled for this API user | Enable it on your account |
| 403 | IP_NOT_WHITELISTED | Your IP is not on the allow-list | Add it on the API Access page |
| 413 | REQUEST_TOO_LARGE | Body over 128 KB | Send a smaller body |
| 429 | RATE_LIMIT_EXCEEDED | Your per-minute or per-day limit is reached | Slow down |
| 429 | TOO_MANY_CONCURRENT_REQUESTS | More than 5 requests running at once | Wait for one to finish (Retry-After) |
| 429 | TOO_MANY_PENDING_TASKS | 100 tasks are still running for this API user | Retry after Retry-After seconds |
| 502 | REQUEST_FAILED | The data could not be retrieved; retryable says whether trying again can help | Retry if retryable is true |
| 503 | SERVICE_BUSY | Temporarily overloaded | Retry after Retry-After seconds |
| 503 | SERVICE_UNAVAILABLE | Temporarily unavailable | Retry after Retry-After seconds |
| 504 | TIMEOUT | The task did not finish in time, e.g. it passed its 10-minute deadline. Not charged | Retry; it is safe |
| 500 | INTERNAL_ERROR | Something went wrong on our side | Retry, or contact support with request_id |
A domain with no data is not an error: the response is 200 with "total": 0 and an empty items list (or zeros in overview, or a row of zeros in traffic_estimate), billed like any other answer.
{
"error": "Unknown parameter 'limt'",
"code": "UNKNOWN_PARAMETER",
"field": "limt",
"hint": "Did you mean 'limit'?",
"retryable": false
}{
"error": "'min_volume' cannot be used with type=overview",
"code": "PARAMETER_NOT_ALLOWED",
"field": "min_volume",
"hint": "Accepted by type: keywords",
"retryable": false
}{
"error": "'limit' 1 ile 1000 arasında bir tam sayı olmalıdır",
"code": "INVALID_PARAMETER",
"field": "limit",
"retryable": false
}{
"error": "Your balance is not enough for this request",
"code": "INSUFFICIENT_CREDITS",
"hint": "Lower limit, or add funds",
"retryable": false,
"required": 0.1452,
"balance": 0.05
}{
"error": "The service is busy, please retry shortly",
"code": "SERVICE_BUSY",
"hint": "You were not charged",
"retryable": true,
"request_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}Credits & Limits
Credits
A base fee per request plus a fee for each row returned:
cost = $0.0132 + (rows / 1000) x $0.132| Request | Cost |
|---|---|
overview (always 1 row) | $0.013332 |
| No data (0 rows) | $0.013200 |
| 10 rows | $0.014520 |
| 100 rows | $0.026400 |
| 1000 rows (maximum) | $0.145200 |
In traffic_estimate every domain sent is a row, found or not: 3 domains cost $0.013596, 1000 cost $0.145200.
history reads a monthly archive that is priced higher, per month returned:
cost = $0.132 + months x $0.00132history request | Cost |
|---|---|
| 3 months | $0.135960 |
| 12 months (default) | $0.147840 |
| 60 months (maximum) | $0.211200 |
The balance hold is the cost of the worst case — limit rows, every domain sent, or every month asked for; anything not returned is refunded immediately. Requesting fewer rows costs less, so set limit to what you need.
Caching
Results are cached for 7 days — the underlying index refreshes monthly. A cached answer is marked "cached": true and is billed at the same price as a live one.
Limits
Per-minute and per-day limits are set on your account (429 RATE_LIMIT_EXCEEDED, see Rate Limits). Independently, at most 5 requests per API user run at the same time; a sixth gets 429 TOO_MANY_CONCURRENT_REQUESTS with a Retry-After header. Cached answers do not count toward it, and a request that has returned 202 keeps its place until its task finishes. At most 100 tasks per API user can be unfinished at once (429 TOO_MANY_PENDING_TASKS). Polling has its own limit and does not count against any of these; see Tasks.
