SEMrush
Category: SEO & Research
Integration type: Tenant-configured API key (stored in integrations table)
External API: SEMrush REST API v3
Purpose
SEMrush is the primary keyword research and competitive analysis tool. The Keyword Researcher agent and Site Auditor agent call SEMrush APIs to:
- Find keyword clusters for a given domain/industry
- Get keyword difficulty, search volume, and CPC data
- Pull competitor domain keyword gaps
- Retrieve domain-level organic traffic estimates
Tenants provide their own SEMrush API key (tied to their SEMrush subscription). The platform does not maintain a shared SEMrush account — every API call bills to the tenant’s SEMrush account.
Config Structure
Tenant integration (stored in integrations table)
// integrations row
{
tenant_id: '<uuid>',
provider: 'semrush',
api_key: '<encrypted SEMrush API key>', // AES-256-GCM
metadata: {
database: 'us', // SEMrush regional database: 'us' | 'uk' | 'in' | 'au' | ...
},
connected_at: TIMESTAMPTZ,
}interface SemrushConfig {
apiKey: string; // SEMrush API key from semrush.com/api/
database: string; // Regional database code (affects keyword data source)
}Supported regional databases
| Code | Region |
|---|---|
us | United States |
uk | United Kingdom |
in | India |
au | Australia |
ca | Canada |
de | Germany |
Integration Pattern
Tool layer (packages/tools/src/semrush.ts)
The SEMrush tool is called by the Keyword Researcher agent via the tool integration layer.
class SemrushTool {
constructor(
private apiKey: string,
private database: string,
) {}
async getKeywordData(keywords: string[]): Promise<KeywordDataRow[]> {
const response = await axios.get('https://api.semrush.com/', {
params: {
type: 'phrase_these',
key: this.apiKey,
phrase: keywords.join(';'),
database: this.database,
export_columns: 'Ph,Nq,Cp,Co,Nr',
// Ph = keyword phrase, Nq = search volume, Cp = CPC, Co = competition, Nr = results
},
});
return this.parseCsvResponse(response.data);
}
async getDomainKeywords(domain: string, limit = 50): Promise<DomainKeywordRow[]> {
const response = await axios.get('https://api.semrush.com/', {
params: {
type: 'domain_organic',
key: this.apiKey,
domain: domain,
database: this.database,
display_limit: limit,
display_sort: 'tr_desc', // Sort by traffic descending
export_columns: 'Ph,Po,Nq,Cp,Ur',
},
});
return this.parseCsvResponse(response.data);
}
async getKeywordGap(
domain: string,
competitors: string[],
): Promise<KeywordGapRow[]> {
const response = await axios.get('https://api.semrush.com/', {
params: {
type: 'phrase_kdi',
key: this.apiKey,
domains: [domain, ...competitors].join(';'),
database: this.database,
display_filter: '+|Po|Lt|4', // Only keywords where domain ranks page 1
},
});
return this.parseCsvResponse(response.data);
}
private parseCsvResponse(raw: string): Record<string, string>[] {
const [header, ...rows] = raw.trim().split('\r\n');
const cols = header.split(';');
return rows.map(row => {
const vals = row.split(';');
return Object.fromEntries(cols.map((c, i) => [c, vals[i]]));
});
}
}API response format
SEMrush returns semicolon-delimited CSV (not JSON). Each call type has different column codes. The tool layer normalises all responses into typed objects before returning them to the agent.
API Units
SEMrush billing is based on API units (not API calls). Different report types cost different numbers of units:
| Report type | Units per row returned |
|---|---|
phrase_these (keyword data) | 10 units per keyword |
domain_organic (domain keywords) | 10 units per row |
phrase_kdi (keyword gap) | 10 units per domain |
Tenants need a SEMrush plan with API access (Business plan or higher). The platform does not enforce unit limits — if a tenant’s account runs out of units, the SEMrush API returns an error that surfaces as an activity failure.
Test Cases
Unit tests (packages/tools/src/semrush.test.ts)
| Test | Approach |
|---|---|
getKeywordData() builds correct API params | Mock axios.get; assert type, key, phrase, database |
getKeywordData() parses CSV response correctly | Feed mock CSV string; assert parsed rows |
getDomainKeywords() returns sorted domain keywords | Mock CSV; assert display_sort=tr_desc in params |
getKeywordGap() includes all domains in params | Assert domains param contains main + competitors |
| Throws on non-200 response | Mock 403; assert error propagated |
Throws on SEMrush error response ERROR 50 :: ... | Feed error string as CSV body; assert detected and thrown |
Integration tests
| Test | Approach |
|---|---|
| Real keyword data for known keyword | Use test API key; fetch seo tools; assert Nq > 0 |
| Domain keywords for known domain | Fetch for semrush.com; assert results returned |
Error Handling
SEMrush API errors are returned as HTTP 200 with body like ERROR 50 :: NOTHING_FOUND. The tool layer must check for this pattern:
if (response.data.startsWith('ERROR')) {
const [, code, message] = response.data.match(/ERROR (\d+) :: (.+)/) ?? [];
throw new SemrushApiError(Number(code), message, { domain, database });
}Common error codes:
| Code | Meaning | Action |
|---|---|---|
| 50 | Nothing found | Return empty array — not a hard failure |
| 120 | No API units remaining | Fail activity with CREDITS_EXHAUSTED |
| 130 | Invalid API key | Fail and prompt tenant to reconnect |
Related
- Tool Integration Layer — how tools are registered and called
- Keyword Researcher Agent — primary consumer of SEMrush
- DataForSEO Provider — alternative/complementary SEO data source
- Ahrefs Provider — backlink and domain authority data