Developer reference
REST API and MCP server
Everything on the dashboard, as JSON for your own reporting or as tools for a coding assistant. Read-only, scoped to your account, same numbers as the dashboard.
Last updated 20 September 2026
Who has it
The API and the MCP server are part of the Agency and Pro plans. A key made on one of those plans stops working the day the subscription lapses: every request checks the subscription, not a flag set when the key was created. On any other plan the endpoints answer 403 with { "code": "api_locked" }.
Both surfaces are read-only by design. Adding brands, questions or topics stays on the dashboard, so nothing holding a key can spend money on your behalf.
Keys
Create keys on the dashboard under API access (/geo/dashboard). A key looks like mdk_live_ followed by 32 characters. The full key is shown exactly once, at creation; we store only a hash of it, so a key we cannot show you again is also a key a leaked database could not reveal. The list shows each key's first 12 characters, when it was made and when it was last used.
- A key reaches every brand on the account that made it, current and future. That is the same scope as the dashboard login that created it, and no wider.
- An account holds up to 10 live keys. Revoke one to make room; revoked keys stay listed so their last-used date is still readable.
- Revocation is immediate. There is no grace period and no way to un-revoke.
Authentication
Send the key as a bearer token on every request, REST or MCP:
Authorization: Bearer mdk_live_…
| Status | Meaning |
|---|---|
401 | No Authorization header, a malformed key, or a key that is invalid or revoked. |
403 api_locked | The key is valid but the account's subscription is not on Agency or Pro, or is not active. |
404 | The brand in the path does not exist or is not owned by the key's account. The two cases are not distinguished. |
400 | The brand id in the path is not a UUID. |
Every response carries Cache-Control: no-store. There is no enforced rate limit today; the data changes once a week, when the scan runs, so polling faster than that returns the same answer.
REST endpoints
Base URL https://www.mediadesk.asia/api/v1. All endpoints are GET.
| Path | Returns |
|---|---|
/brands | Every brand on the account, with market and tracked-question count. Call this first to get brand ids. |
/brands/{brandId}/summary | The latest finished scan's rollup: visibility score overall and per engine with week-over-week deltas, citation rates, tone, per-topic and per-language tallies, competitors, the full per-question matrix and the losing questions. Identical to the dashboard. |
/brands/{brandId}/results | Raw rows, one per question × engine × scan, straight from the record. The CSV export as JSON. ?scope=latest (default) or all; ?limit= up to 5000. |
/brands/{brandId}/recommendations | One recommendation per losing question in the latest scan, with kind, evidence, any MediaDesk publisher placement that would answer it, and status. |
/brands/{brandId}/topics | The brand's question topics with prompt counts. Per-topic rates and scores are in the summary. |
Example
curl https://www.mediadesk.asia/api/v1/brands \
-H "Authorization: Bearer mdk_live_…"
{
"brands": [
{
"id": "baaea6ea-…",
"name": "OasisEye",
"domain": "oasiseye.my",
"market": "MY",
"prompts": 27,
"createdAt": "2026-08-12T09:14:02.000Z"
}
]
}curl "https://www.mediadesk.asia/api/v1/brands/baaea6ea-…/results?scope=latest&limit=2" \
-H "Authorization: Bearer mdk_live_…"
{
"brand": { "id": "baaea6ea-…", "name": "OasisEye", "domain": "oasiseye.my", "market": "MY" },
"scans": [
{ "id": "…", "label": "Weekly scan", "startedAt": "…", "completedAt": "…", "status": "completed" }
],
"results": [
{
"scanId": "…",
"promptId": "…",
"question": "best eye clinic for LASIK in Kuala Lumpur",
"questionType": "recommendation",
"topic": "LASIK",
"language": "en",
"engine": "gemini",
"verdict": "cited",
"votesFor": 3,
"votesTotal": 3,
"position": "2nd of 8 sources",
"citedUrl": "https://oasiseye.my/lasik",
"sentiment": "positive",
"competitors": ["…"],
"sources": [{ "url": "…", "title": "…" }],
"scannedAt": "…"
}
]
}Reading the fields
The API returns the same objects the dashboard renders, so the rules the dashboard follows apply to what you pull. Three of them matter when you build a report on top of it.
A verdict has four values, and two of them are not zero
| Verdict | Meaning |
|---|---|
cited | The engine's answer named the brand or cited its site, by majority across repeat runs. |
not_cited | The engine searched and answered, and the brand was not in it. |
no_data | No run produced a grounded answer (the engine replied without searching, or every run errored). There is no verdict either way. The raw results endpoint never backfills this; treat it as missing, not as not_cited. |
not_scanned | The engine is not part of the plan's scan for this brand. Appears only in the summary's question matrix and in each engine's status. |
Rates and scores are null when nothing was measured
rate is cited over decided answers, and is null when decided is zero. visibility is the published 0–100 score (formula) or null. A nullis "not measured", never "scored zero"; a chart that plots it as 0 will misreport the brand. Every delta is null when either week is missing.
Votes tell you how sure the verdict is
votesFor / votesTotal are the repeat runs that saw the brand over the runs that searched at all. A 3/3 and a 2/3 are the same verdict and different evidence; week-over-week comparisons should carry the counts, not just the boolean. Both are null on a no_data row.
Recommendation kinds
claim_mention— the engines name the brand in the answer but link elsewhere for the facts; the gap is a first-party page to cite.localise_question— an English, non-branded question for a Malaysian brand where every winning source sits outside the market.earn_placement— a third-party publisher wins the question;placementslists any MediaDesk publisher among the winners.publish_page— none of the above: the question is won by pages the brand has no equivalent of.
status is open, done or dismissed, set on the dashboard and carried week to week. evidence.author is llm when the copy was written from the scan's evidence and template when the writer was unavailable and a templated line was stored instead.
MCP server
The same read surface as tools, at https://www.mediadesk.asia/api/mcp, so a coding assistant can answer "how did OasisEye do on Gemini this week" from the record instead of from memory. Streamable HTTP, stateless, authenticated with the same key as a bearer header. An assistant quoting a number gets the dashboard's number.
| Tool | Returns |
|---|---|
list_brands | Brands on the account with ids. Assistants are told to call this first. |
get_summary | The summary above, minus the per-question matrix. |
get_questions | The per-question matrix: topic, language, per-engine verdict, position and tone. |
get_results | Raw rows; scope latest or all, limit up to 2000 (default 500). |
get_recommendations | Recommendations, as above. |
get_topics | Topics with counts. |
The server's own instructions to the assistant say what this page says: a null score or a not_scanned engine is no measurement, never zero.
Claude Code
claude mcp add --transport http mediadesk https://www.mediadesk.asia/api/mcp \ --header "Authorization: Bearer mdk_live_…"
Cursor
Add to .cursor/mcp.json in the project, or ~/.cursor/mcp.json for every project:
{
"mcpServers": {
"mediadesk": {
"url": "https://www.mediadesk.asia/api/mcp",
"headers": { "Authorization": "Bearer mdk_live_…" }
}
}
}Codex
In ~/.codex/config.toml, with the key in an environment variable so it does not sit in the config file:
[mcp_servers.mediadesk] url = "https://www.mediadesk.asia/api/mcp" bearer_token_env_var = "MEDIADESK_API_KEY"
Then export MEDIADESK_API_KEY=mdk_live_… before starting Codex.
Anything else
Any MCP client that speaks streamable HTTP and can send a static header works with the Cursor-style config above. The server answers an unauthenticated request with a 401 that also carries an OAuth resource-metadata challenge; clients that send the bearer header can ignore it.
Keys are secrets. Do not commit them, paste them into a shared MCP config, or put them in a URL. If one leaks, revoke it on the dashboard and make another; the old one stops working at once.