API Platform
Domain Research API
See which keywords any domain ranks for, who its organic competitors are, which pages earn its traffic, and a one-call summary of its search visibility.
See which keywords any domain ranks for, who its organic competitors are, which pages earn its traffic, and a one-call summary of its search visibility. One endpoint serves four reports, selected with the type parameter. Turkish data covers more than 61 million keywords.
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 |
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.
typestringdefault: overviewoverview, keywords, competitors or top_pages.
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. Not accepted on overview.
pageintegerdefault: 1Page number. page × limit can be at most 10,000. Not accepted on overview.
sortstringkeywords: traffic (default), volume, position, difficulty, cpc. competitors: common_keywords (default), avg_position. top_pages: traffic.
directionstringdesc or asc. Defaults to asc for position and avg_position (best first) and desc for everything else.
min_volumeintegerkeywords only. 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 only. informational, navigational, commercial or transactional.
containsstringkeywords only. 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 within 24 hours returns the stored response at no charge, so a timed-out request can be retried safely.
{
"type": "keywords",
"domain": "example.com.tr",
"limit": 50,
"min_volume": 100,
"max_position": 10,
"intent": "commercial",
"contains": "avukat",
"sort": "volume"
}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.
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=120,
)
data = response.json()
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.ok) {
throw new Error(`${data.code}: ${data.error}`);
}
console.log(`${data.count} of ${data.total} keywords, cost $${data.cost}`);Response
The list reports return 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.
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"
}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 |
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 (list 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 | 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 |
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 → market → values.
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 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 | TYPE_UNAVAILABLE | A report that is announced but not open yet | 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 | INVALID_DOMAIN | Not a domain (an IP, localhost, no TLD…) | 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 |
| 409 | REQUEST_IN_PROGRESS | The same idempotency_key is still running | Retry when it finishes |
| 413 | REQUEST_TOO_LARGE | Body over 16 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) |
| 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 request took too long | 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), billed at the base fee.
{
"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 |
The balance hold is the cost of limit rows; 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.
