{"openapi":"3.0.0","paths":{"/api/v1/dns/propagation":{"get":{"operationId":"getDnsPropagation","summary":"Check DNS propagation","description":"Asks public resolvers worldwide whether `domain` already resolves to `expected_ip` right now. Results for the same domain and IP are cached for 60 seconds. Limited to 10 checks per minute per key.","parameters":[{"name":"domain","required":true,"in":"query","description":"Public domain to check (A record for an IPv4 expected_ip, AAAA for IPv6).","schema":{"example":"shop.example.com","type":"string"}},{"name":"expected_ip","required":true,"in":"query","description":"The IP the domain should resolve to after the switch (public addresses only).","schema":{"example":"203.0.113.10","type":"string"}}],"responses":{"200":{"description":"The current propagation snapshot","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per window for this key (and route)","schema":{"type":"integer","example":60}},"X-RateLimit-Remaining":{"description":"Requests left in the current window","schema":{"type":"integer","example":59}},"X-RateLimit-Reset":{"description":"Seconds until the window resets","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1DnsPropagation"}}}},"400":{"description":"VALIDATION_FAILED, or a non-public domain or IP","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"UNAUTHORIZED (missing, unknown, rotated or revoked key) or API_KEY_EXPIRED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"402":{"description":"PLAN_REQUIRED: the workspace plan does not include API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"API_KEY_IP_NOT_ALLOWED / IP_NOT_ALLOWED (address not allowed) or API_KEY_SCOPE (read-only key on a write route)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"423":{"description":"WORKSPACE_SUSPENDED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"RATE_LIMITED: too many requests for this key; wait Retry-After seconds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"502":{"description":"UPSTREAM_UNAVAILABLE: the DNS checker did not answer; retry later","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}},"tags":["DNS"],"security":[{"ApiKey":[]}]}},"/api/v1/links":{"get":{"operationId":"listLinks","summary":"List links","description":"Links of the key's workspace, newest first, one page at a time: links shared with the workspace plus the private links of the key's creator. Optionally only one space (`space`) or links matching a search (`q`); `total` counts the filtered set.","parameters":[{"name":"limit","required":false,"in":"query","description":"Items per page (1–100). Default: 100 for links, 20 for QA reports.","schema":{"minimum":1,"maximum":100,"example":20,"type":"number"}},{"name":"cursor","required":false,"in":"query","description":"The `nextCursor` of the previous page; omit for the first (newest) page.","schema":{"example":"eyJ0IjoiMjAyNi0wOS0yOFQxMjowMDowMC4xMjM0NTZaIiwiaSI6IjNmYTg1ZjY0LTU3MTctNDU2Mi1iM2ZjLTJjOTYzZjY2YWZhNiJ9","type":"string"}},{"name":"space","required":false,"in":"query","description":"Only the links of this space (its id, `spaceId` on a link), or `none` for links in no space. 404 SPACE_NOT_FOUND for an id that is not a space of the key's workspace. Keep the same value while following `nextCursor`.","schema":{"example":"none","type":"string"}},{"name":"q","required":false,"in":"query","description":"Search, case-insensitive and trimmed: links whose alias, targetDomain, targetIp or liveDomain contains this text (`%` and `_` match literally). Keep the same value while following `nextCursor`.","schema":{"maxLength":100,"example":"shop","type":"string"}}],"responses":{"200":{"description":"A page of links","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per window for this key (and route)","schema":{"type":"integer","example":60}},"X-RateLimit-Remaining":{"description":"Requests left in the current window","schema":{"type":"integer","example":59}},"X-RateLimit-Reset":{"description":"Seconds until the window resets","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1LinkPage"}}}},"400":{"description":"VALIDATION_FAILED or INVALID_CURSOR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"UNAUTHORIZED (missing, unknown, rotated or revoked key) or API_KEY_EXPIRED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"402":{"description":"PLAN_REQUIRED: the workspace plan does not include API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"API_KEY_IP_NOT_ALLOWED / IP_NOT_ALLOWED (address not allowed) or API_KEY_SCOPE (read-only key on a write route)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"SPACE_NOT_FOUND: `space` is not a space of the key's workspace","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"423":{"description":"WORKSPACE_SUSPENDED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"RATE_LIMITED: too many requests for this key; wait Retry-After seconds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}},"tags":["Links"],"security":[{"ApiKey":[]}]},"post":{"operationId":"createLink","summary":"Create a link","description":"Creates a preview link of `targetDomain` served from `targetIp`. The server check and the security scan start in the background.","parameters":[{"name":"Idempotency-Key","in":"header","description":"Any unique string (e.g. a UUID), 1–255 visible ASCII characters. A retry with the same key and body within 24 hours returns the first response (header `Idempotency-Replayed: true`) instead of acting twice; the same key with a different body is 422 IDEMPOTENCY_KEY_REUSED.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLinkDto"}}}},"responses":{"201":{"description":"The new link","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per window for this key (and route)","schema":{"type":"integer","example":60}},"X-RateLimit-Remaining":{"description":"Requests left in the current window","schema":{"type":"integer","example":59}},"X-RateLimit-Reset":{"description":"Seconds until the window resets","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1CreatedLink"}}}},"400":{"description":"VALIDATION_FAILED, a private or invalid IP, a blocked target, or a POLICY_* workspace rule","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"UNAUTHORIZED (missing, unknown, rotated or revoked key) or API_KEY_EXPIRED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"402":{"description":"PLAN_REQUIRED: the workspace plan does not include API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"API_KEY_IP_NOT_ALLOWED / IP_NOT_ALLOWED (address not allowed) or API_KEY_SCOPE (read-only key on a write route)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"409":{"description":"CONFLICT: the alias is taken; or IDEMPOTENCY_KEY_IN_PROGRESS","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"422":{"description":"IDEMPOTENCY_KEY_REUSED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"423":{"description":"WORKSPACE_SUSPENDED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"RATE_LIMITED: too many requests for this key; wait Retry-After seconds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}},"tags":["Links"],"security":[{"ApiKey":[]}]}},"/api/v1/links/{alias}":{"get":{"operationId":"getLink","summary":"Get a link","description":"The link with its preview URL, status, settings, latest server check and security result.","parameters":[{"name":"alias","required":true,"in":"path","description":"The link alias (the subdomain of its preview URL)","schema":{"example":"shop-staging","type":"string"}}],"responses":{"200":{"description":"The link","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per window for this key (and route)","schema":{"type":"integer","example":60}},"X-RateLimit-Remaining":{"description":"Requests left in the current window","schema":{"type":"integer","example":59}},"X-RateLimit-Reset":{"description":"Seconds until the window resets","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Link"}}}},"401":{"description":"UNAUTHORIZED (missing, unknown, rotated or revoked key) or API_KEY_EXPIRED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"402":{"description":"PLAN_REQUIRED: the workspace plan does not include API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"API_KEY_IP_NOT_ALLOWED / IP_NOT_ALLOWED (address not allowed) or API_KEY_SCOPE (read-only key on a write route)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"LINK_NOT_FOUND (also for links of other workspaces and other members' private links)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"423":{"description":"WORKSPACE_SUSPENDED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"RATE_LIMITED: too many requests for this key; wait Retry-After seconds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}},"tags":["Links"],"security":[{"ApiKey":[]}]},"patch":{"operationId":"updateLink","summary":"Update a link","description":"Turns the preview on or off, sets or removes the password and the expiry, moves it to another server or to another space. Only the fields you send change. A new target re-runs the server check.","parameters":[{"name":"alias","required":true,"in":"path","description":"The link alias (the subdomain of its preview URL)","schema":{"example":"shop-staging","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1UpdateLinkDto"},"examples":{"disable":{"summary":"Turn the preview off","value":{"enabled":false}},"move":{"summary":"Point to a new server","value":{"targetIp":"203.0.113.20"}},"protect":{"summary":"Password and expiry","value":{"password":"client-review-2026","expiresAt":"2026-12-31T23:59:59Z"}},"clear":{"summary":"Remove password and expiry","value":{"password":null,"expiresAt":null}},"private":{"summary":"Keep the link to yourself","value":{"visibility":"private"}},"subdomains":{"summary":"Preview subdomains too","value":{"wildcard":true}},"space":{"summary":"File under a space","value":{"spaceId":"3fa85f64-5717-4562-b3fc-2c963f66afa6"}},"unsorted":{"summary":"Take out of its space","value":{"spaceId":null}}}}}},"responses":{"200":{"description":"The updated link","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per window for this key (and route)","schema":{"type":"integer","example":60}},"X-RateLimit-Remaining":{"description":"Requests left in the current window","schema":{"type":"integer","example":59}},"X-RateLimit-Reset":{"description":"Seconds until the window resets","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Link"}}}},"400":{"description":"VALIDATION_FAILED (also for an empty body), a private or invalid IP, a blocked target, LIVE_DOMAIN_INVALID / LIVE_IP_REQUIRES_DOMAIN, or a POLICY_* workspace rule","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"UNAUTHORIZED (missing, unknown, rotated or revoked key) or API_KEY_EXPIRED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"402":{"description":"PLAN_REQUIRED: the workspace plan does not include API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"API_KEY_IP_NOT_ALLOWED / IP_NOT_ALLOWED, API_KEY_SCOPE (read-only key), LINK_BLOCKED_BY_SAFETY_CHECK, or FORBIDDEN: changing `visibility` of a link someone else created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"LINK_NOT_FOUND, or SPACE_NOT_FOUND for a `spaceId` that is not a space of the workspace","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"423":{"description":"WORKSPACE_SUSPENDED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"RATE_LIMITED: too many requests for this key; wait Retry-After seconds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}},"tags":["Links"],"security":[{"ApiKey":[]}]},"delete":{"operationId":"deleteLink","summary":"Delete a link","description":"Members can delete only links they created; owners and admins any link.","parameters":[{"name":"alias","required":true,"in":"path","description":"The link alias (the subdomain of its preview URL)","schema":{"example":"shop-staging","type":"string"}}],"responses":{"200":{"description":"Deleted (empty body)"},"401":{"description":"UNAUTHORIZED (missing, unknown, rotated or revoked key) or API_KEY_EXPIRED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"402":{"description":"PLAN_REQUIRED: the workspace plan does not include API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"API_KEY_IP_NOT_ALLOWED / IP_NOT_ALLOWED (address not allowed) or API_KEY_SCOPE (read-only key on a write route)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"LINK_NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"423":{"description":"WORKSPACE_SUSPENDED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"RATE_LIMITED: too many requests for this key; wait Retry-After seconds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}},"tags":["Links"],"security":[{"ApiKey":[]}]}},"/api/v1/links/{alias}/qa-reports":{"post":{"operationId":"createQaReport","summary":"Run a QA report","description":"Crawls the site on the link's new server (as the preview does) and builds a pre-launch QA report: broken pages, mixed content, redirects, SEO and security findings. Uses one QA report of the workspace's monthly quota. Poll GET /v1/qa-reports/{id} until `status` is completed or failed.\n\nThe optional body sets run settings (page budget, path filters, viewports, speed, robots.txt, live comparison, probes, notifications, staging credentials); fields left out use the link's default run settings from the dashboard. Staging credentials are encrypted at rest, never returned, and sent only to the link's domain on the new server.","parameters":[{"name":"alias","required":true,"in":"path","description":"The link alias (the subdomain of its preview URL)","schema":{"example":"shop-staging","type":"string"}},{"name":"Idempotency-Key","in":"header","description":"Any unique string (e.g. a UUID), 1–255 visible ASCII characters. A retry with the same key and body within 24 hours returns the first response (header `Idempotency-Replayed: true`) instead of acting twice; the same key with a different body is 422 IDEMPOTENCY_KEY_REUSED.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":false,"description":"Run settings (all optional).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1CreateQaReportDto"}}}},"responses":{"201":{"description":"The queued report","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per window for this key (and route)","schema":{"type":"integer","example":60}},"X-RateLimit-Remaining":{"description":"Requests left in the current window","schema":{"type":"integer","example":59}},"X-RateLimit-Reset":{"description":"Seconds until the window resets","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1QaReport"}}}},"400":{"description":"VALIDATION_FAILED: invalid run settings (path patterns, header name, missing staging secret)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"UNAUTHORIZED (missing, unknown, rotated or revoked key) or API_KEY_EXPIRED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"402":{"description":"QA_QUOTA_EXCEEDED: the monthly QA report quota of the plan is used up (or the plan has none); PLAN_LIMIT: run settings beyond the plan (more pages than the plan allows, fast crawl)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"API_KEY_IP_NOT_ALLOWED / IP_NOT_ALLOWED (address not allowed) or API_KEY_SCOPE (read-only key on a write route)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"LINK_NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"409":{"description":"IDEMPOTENCY_KEY_IN_PROGRESS","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"422":{"description":"IDEMPOTENCY_KEY_REUSED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"423":{"description":"WORKSPACE_SUSPENDED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"RATE_LIMITED: too many requests for this key; wait Retry-After seconds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"SERVICE_UNAVAILABLE: background jobs are unavailable; retry later (the quota is not used)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}},"tags":["QA reports"],"security":[{"ApiKey":[]}]},"get":{"operationId":"listQaReports","summary":"List QA reports of a link","description":"Newest first, one page at a time.","parameters":[{"name":"alias","required":true,"in":"path","description":"The link alias (the subdomain of its preview URL)","schema":{"example":"shop-staging","type":"string"}},{"name":"limit","required":false,"in":"query","description":"Items per page (1–100). Default: 100 for links, 20 for QA reports.","schema":{"minimum":1,"maximum":100,"example":20,"type":"number"}},{"name":"cursor","required":false,"in":"query","description":"The `nextCursor` of the previous page; omit for the first (newest) page.","schema":{"example":"eyJ0IjoiMjAyNi0wOS0yOFQxMjowMDowMC4xMjM0NTZaIiwiaSI6IjNmYTg1ZjY0LTU3MTctNDU2Mi1iM2ZjLTJjOTYzZjY2YWZhNiJ9","type":"string"}}],"responses":{"200":{"description":"A page of QA reports","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per window for this key (and route)","schema":{"type":"integer","example":60}},"X-RateLimit-Remaining":{"description":"Requests left in the current window","schema":{"type":"integer","example":59}},"X-RateLimit-Reset":{"description":"Seconds until the window resets","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1QaReportPage"}}}},"400":{"description":"VALIDATION_FAILED or INVALID_CURSOR","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"UNAUTHORIZED (missing, unknown, rotated or revoked key) or API_KEY_EXPIRED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"402":{"description":"PLAN_REQUIRED: the workspace plan does not include API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"API_KEY_IP_NOT_ALLOWED / IP_NOT_ALLOWED (address not allowed) or API_KEY_SCOPE (read-only key on a write route)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"LINK_NOT_FOUND","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"423":{"description":"WORKSPACE_SUSPENDED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"RATE_LIMITED: too many requests for this key; wait Retry-After seconds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}},"tags":["QA reports"],"security":[{"ApiKey":[]}]}},"/api/v1/qa-reports/{id}":{"get":{"operationId":"getQaReport","summary":"Get a QA report","description":"Status and summary: score, verdict, finding counts and links to the full report.","parameters":[{"name":"id","required":true,"in":"path","description":"The report id returned when the run started","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"The report","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per window for this key (and route)","schema":{"type":"integer","example":60}},"X-RateLimit-Remaining":{"description":"Requests left in the current window","schema":{"type":"integer","example":59}},"X-RateLimit-Reset":{"description":"Seconds until the window resets","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1QaReport"}}}},"401":{"description":"UNAUTHORIZED (missing, unknown, rotated or revoked key) or API_KEY_EXPIRED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"402":{"description":"PLAN_REQUIRED: the workspace plan does not include API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"API_KEY_IP_NOT_ALLOWED / IP_NOT_ALLOWED (address not allowed) or API_KEY_SCOPE (read-only key on a write route)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"QA_REPORT_NOT_FOUND (also for reports of other workspaces)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"423":{"description":"WORKSPACE_SUSPENDED","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"RATE_LIMITED: too many requests for this key; wait Retry-After seconds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}},"tags":["QA reports"],"security":[{"ApiKey":[]}]}}},"info":{"title":"DNSPeek REST API v1","description":"Create and manage DNSPeek preview links, run pre-launch QA reports and check DNS propagation from CI pipelines, hosting panels and scripts.\n\n## Authentication\nSend a workspace API key as a bearer token: `Authorization: Bearer dpk_<prefix>_<secret>`. Create keys on the API Keys page of the dashboard (plans with API access). A key acts in its workspace as the member who created it. **Read-only** keys can call GET routes only; other routes answer 403 `API_KEY_SCOPE`.\n\n## Errors\nEvery error has the same body: `{ \"error\": { \"code\": \"LINK_NOT_FOUND\", \"message\": \"…\", \"details\": { … } } }`. Branch on `error.code` (stable); `error.message` is for people. The body also repeats `statusCode`, `code` and `message` at the top level for early v1 clients.\n\n## Pagination\nList routes return `{ items, total, limit, nextCursor }`, newest first. Pass `nextCursor` back as `?cursor=` for the next page; it is `null` on the last page. `limit` is 1–100.\n\n## Idempotency\nPOST routes accept an optional `Idempotency-Key` header (for example a UUID). Retrying with the same key and body within 24 hours returns the first response with `Idempotency-Replayed: true` instead of creating a second link or QA run. The same key with a different body answers 422 `IDEMPOTENCY_KEY_REUSED`.\n\n## Rate limits\nPer key and route, by the plan of the key's workspace: Pro 60, Agency 300, Provider 1,000 requests per minute (DNS propagation: 10 per minute on every plan). Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; over the limit the API answers 429 `RATE_LIMITED` with `Retry-After` (seconds).","version":"1.0","contact":{}},"tags":[{"name":"Links","description":"Preview links: a site on a new server, served before the DNS switch"},{"name":"QA reports","description":"Pre-launch crawls of the new server: score, verdict and findings"},{"name":"DNS","description":"Propagation of a domain towards the new server"}],"servers":[{"url":"https://api.dnspeek.link","description":"DNSPeek API"}],"components":{"securitySchemes":{"ApiKey":{"scheme":"bearer","bearerFormat":"dpk_<prefix>_<secret>","type":"http","description":"Workspace API key (dashboard → API Keys)"}},"schemas":{"V1ErrorDetail":{"type":"object","properties":{"code":{"type":"string","example":"LINK_NOT_FOUND","description":"Stable, machine-readable error code (docs/api/v1.md § Errors)."},"message":{"type":"string","example":"Link not found","description":"Human-readable explanation; may change, do not parse it."},"details":{"type":"object","additionalProperties":true,"description":"Extra fields of this error, e.g. `errors` (validation), `required` (scope), `retryAfter`.","example":{"errors":["targetIp must be shorter than or equal to 45 characters"]}}},"required":["code","message"]},"V1Error":{"type":"object","properties":{"error":{"$ref":"#/components/schemas/V1ErrorDetail"},"statusCode":{"type":"number","example":404,"description":"HTTP status (kept at the top level for early v1 clients)."},"code":{"type":"string","example":"LINK_NOT_FOUND","description":"Same as error.code (kept for early v1 clients)."},"message":{"type":"string","example":"Link not found","description":"Same as error.message (kept for early v1 clients)."}},"required":["error","statusCode","code","message"]},"V1LinkSecurity":{"type":"object","properties":{"status":{"type":"string","enum":["clean","flagged","needs_review","suspicious","scanning","unchecked"]},"primary":{"type":"object","nullable":true,"additionalProperties":true,"description":"Primary reputation lookup (Google Web Risk / Safe Browsing)."},"deepScan":{"type":"object","nullable":true,"additionalProperties":true,"description":"Deep scan of the rendered site."}},"required":["status","primary","deepScan"]},"V1Link":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"3fa85f64-5717-4562-b3fc-2c963f66afa6"},"alias":{"type":"string","example":"shop-staging"},"status":{"type":"string","enum":["active","disabled","blocked","expired"],"description":"active: served; disabled: turned off by the workspace; blocked: disabled by DNSPeek (see blockedReason); expired: past expiresAt."},"previewUrl":{"type":"string","example":"https://shop-staging.dnspeek-preview.link/","description":"Shareable preview of the site on the new server."},"dashboardUrl":{"type":"string","example":"https://dnspeek.link/links/3fa85f64-5717-4562-b3fc-2c963f66afa6","description":"The link in the dashboard (sign-in required)."},"targetDomain":{"type":"string","example":"shop.example.com"},"targetIp":{"type":"string","example":"203.0.113.10"},"liveDomain":{"type":"string","nullable":true,"example":"old-domain.com","description":"Old site address for Live compare and visual compares; null: the old side opens targetDomain via public DNS."},"liveIp":{"type":"string","nullable":true,"example":null,"description":"Old server IP pinned for liveDomain; null: public DNS."},"httpPort":{"type":"number","example":80},"httpsPort":{"type":"number","example":443},"sslMode":{"type":"string","enum":["auto","skip","verify","off"]},"hasPassword":{"type":"boolean","description":"Visitors must enter a password."},"disabled":{"type":"boolean"},"blockedReason":{"type":"string","nullable":true,"enum":["safe_browsing_phishing","safe_browsing_malware","web_risk_phishing","web_risk_malware","security_review","moderation"],"description":"Why DNSPeek (not the workspace) disabled the link."},"expiresAt":{"type":"string","format":"date-time","nullable":true},"spaceId":{"type":"string","format":"uuid","nullable":true},"commentsEnabled":{"type":"boolean","description":"Client comments widget on the preview (Agency plan and up)."},"wildcard":{"type":"boolean","description":"Subdomains are previewed too: shop.<targetDomain> opens at https://shop---<alias>.<preview domain>/ (Pro plan and up)."},"dnsTrackingEnabled":{"type":"boolean"},"preflight":{"type":"object","nullable":true,"additionalProperties":true,"description":"Latest server check of the target (reachability, TLS); status `pending` while it runs."},"security":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/V1LinkSecurity"}]},"userId":{"type":"string","format":"uuid","nullable":true,"description":"Member who created the link."},"workspaceId":{"type":"string","format":"uuid"},"visibility":{"type":"string","enum":["workspace","private"],"example":"workspace","description":"workspace: every member sees the link; private: only its creator (the API key's creator lists and reads it, nobody else in the workspace). The preview URL works either way."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","alias","status","previewUrl","dashboardUrl","targetDomain","targetIp","liveDomain","liveIp","httpPort","httpsPort","sslMode","hasPassword","disabled","blockedReason","expiresAt","spaceId","commentsEnabled","wildcard","dnsTrackingEnabled","preflight","security","userId","workspaceId","visibility","createdAt","updatedAt"]},"V1LinkPage":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/V1Link"}},"total":{"type":"number","example":42,"description":"Links in the workspace (all pages)."},"limit":{"type":"number","example":100},"nextCursor":{"type":"string","nullable":true,"description":"Pass as `cursor` for the next page; null on the last page."}},"required":["items","total","limit","nextCursor"]},"CreateLinkDto":{"type":"object","properties":{"spaceId":{"type":"string","format":"uuid","description":"One of your spaces; not available to guests"},"targetDomain":{"type":"string","example":"example.com"},"targetIp":{"type":"string","example":"93.184.216.34"},"alias":{"type":"string","example":"my-preview"},"httpPort":{"type":"number","default":80},"httpsPort":{"type":"number","default":443},"sslMode":{"type":"string","enum":["auto","skip","verify","off"],"default":"auto","description":"How the proxy connects to the target server. auto: HTTPS, falling back to plain HTTP; verify: HTTPS with a trusted certificate only; skip: HTTPS without certificate verification (self-signed); off: plain HTTP only, for an origin without SSL (visitors still get HTTPS; the origin receives X-Forwarded-Proto: https)."},"password":{"type":"string","description":"Optional password to protect preview"},"expiresAt":{"type":"string","description":"ISO8601 expiry date"},"visibility":{"type":"string","enum":["workspace","private"],"default":"workspace","description":"workspace: every member of the workspace sees the link; private: only you (its creator) see it in the dashboard and API. Preview URLs work for anyone with the link either way."},"liveDomain":{"type":"string","nullable":true,"example":"old-domain.com","description":"Old site address for Live compare, Find differences and visual compares: the domain the old server serves the site on (for example before a rebrand). Hostname only, no scheme or path. Null or absent: the old side opens targetDomain via public DNS."},"liveIp":{"type":"string","nullable":true,"example":"192.33.4.12","description":"Optional IP of the old server (public addresses only) used for liveDomain instead of public DNS. Requires liveDomain (400 LIVE_IP_REQUIRES_DOMAIN otherwise)."},"interstitialDisabled":{"type":"boolean","default":false,"description":"true: visitors open the preview directly, without the DNSPeek notice \"Preview of <domain> on a new server\". Paid plans only (402 otherwise); guest and Free links always show the notice."},"wildcard":{"type":"boolean","default":false,"description":"Also preview every subdomain of targetDomain (WordPress multisite, shop. or blog. hosts) as <sub>---<alias>.<preview domain>, served from the same targetIp. Pro plan and up (402 otherwise); not available to guests."}},"required":["targetDomain","targetIp"]},"V1TargetHint":{"type":"object","properties":{"kind":{"type":"string","enum":["hosted-builder"]},"platform":{"type":"string","example":"Shopify"},"message":{"type":"string"},"previewUrl":{"type":"string","example":"your-store.myshopify.com"},"docsUrl":{"type":"string"}},"required":["kind","platform","message","previewUrl","docsUrl"]},"V1CreatedLink":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"3fa85f64-5717-4562-b3fc-2c963f66afa6"},"alias":{"type":"string","example":"shop-staging"},"status":{"type":"string","enum":["active","disabled","blocked","expired"],"description":"active: served; disabled: turned off by the workspace; blocked: disabled by DNSPeek (see blockedReason); expired: past expiresAt."},"previewUrl":{"type":"string","example":"https://shop-staging.dnspeek-preview.link/","description":"Shareable preview of the site on the new server."},"dashboardUrl":{"type":"string","example":"https://dnspeek.link/links/3fa85f64-5717-4562-b3fc-2c963f66afa6","description":"The link in the dashboard (sign-in required)."},"targetDomain":{"type":"string","example":"shop.example.com"},"targetIp":{"type":"string","example":"203.0.113.10"},"liveDomain":{"type":"string","nullable":true,"example":"old-domain.com","description":"Old site address for Live compare and visual compares; null: the old side opens targetDomain via public DNS."},"liveIp":{"type":"string","nullable":true,"example":null,"description":"Old server IP pinned for liveDomain; null: public DNS."},"httpPort":{"type":"number","example":80},"httpsPort":{"type":"number","example":443},"sslMode":{"type":"string","enum":["auto","skip","verify","off"]},"hasPassword":{"type":"boolean","description":"Visitors must enter a password."},"disabled":{"type":"boolean"},"blockedReason":{"type":"string","nullable":true,"enum":["safe_browsing_phishing","safe_browsing_malware","web_risk_phishing","web_risk_malware","security_review","moderation"],"description":"Why DNSPeek (not the workspace) disabled the link."},"expiresAt":{"type":"string","format":"date-time","nullable":true},"spaceId":{"type":"string","format":"uuid","nullable":true},"commentsEnabled":{"type":"boolean","description":"Client comments widget on the preview (Agency plan and up)."},"wildcard":{"type":"boolean","description":"Subdomains are previewed too: shop.<targetDomain> opens at https://shop---<alias>.<preview domain>/ (Pro plan and up)."},"dnsTrackingEnabled":{"type":"boolean"},"preflight":{"type":"object","nullable":true,"additionalProperties":true,"description":"Latest server check of the target (reachability, TLS); status `pending` while it runs."},"security":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/V1LinkSecurity"}]},"userId":{"type":"string","format":"uuid","nullable":true,"description":"Member who created the link."},"workspaceId":{"type":"string","format":"uuid"},"visibility":{"type":"string","enum":["workspace","private"],"example":"workspace","description":"workspace: every member sees the link; private: only its creator (the API key's creator lists and reads it, nobody else in the workspace). The preview URL works either way."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"targetHints":{"description":"Non-blocking warnings about the target, e.g. a hosted site builder IP.","type":"array","items":{"$ref":"#/components/schemas/V1TargetHint"}}},"required":["id","alias","status","previewUrl","dashboardUrl","targetDomain","targetIp","liveDomain","liveIp","httpPort","httpsPort","sslMode","hasPassword","disabled","blockedReason","expiresAt","spaceId","commentsEnabled","wildcard","dnsTrackingEnabled","preflight","security","userId","workspaceId","visibility","createdAt","updatedAt","targetHints"]},"V1UpdateLinkDto":{"type":"object","properties":{"enabled":{"type":"boolean","description":"false turns the preview off, true turns it back on (not possible for links DNSPeek blocked: 403 LINK_BLOCKED_BY_SAFETY_CHECK).","example":false},"password":{"type":"string","nullable":true,"maxLength":128,"description":"New visitor password; null removes it (400 POLICY_PASSWORD_REQUIRED when a workspace policy requires one).","example":"client-review-2026"},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"New expiry (ISO 8601); null removes it. Plan limits and workspace policies apply, measured from link creation.","example":"2026-12-31T23:59:59Z"},"targetIp":{"type":"string","description":"New target server IP (public addresses only; re-runs the server check and the security scan).","example":"203.0.113.10"},"liveDomain":{"type":"string","nullable":true,"maxLength":253,"description":"Old site address for Live compare, Find differences and visual compares: the domain the old server serves the site on (e.g. before a rebrand). null clears it (400 LIVE_IP_REQUIRES_DOMAIN while liveIp stays set); 400 LIVE_DOMAIN_INVALID for anything but a public hostname.","example":"old-domain.com"},"liveIp":{"type":"string","nullable":true,"maxLength":45,"description":"IP of the old server used for liveDomain instead of public DNS (public addresses only). null clears it. Requires liveDomain (400 LIVE_IP_REQUIRES_DOMAIN).","example":"192.33.4.12"},"httpPort":{"type":"number","minimum":1,"maximum":65535,"example":80},"httpsPort":{"type":"number","minimum":1,"maximum":65535,"example":443},"sslMode":{"type":"string","enum":["auto","skip","verify","off"],"description":"How the proxy connects to the target server: auto, verify, skip (no certificate verification) or off (plain HTTP only, for an origin without SSL)."},"visibility":{"type":"string","enum":["workspace","private"],"description":"private: only the link's creator sees it in the dashboard and API; workspace: every member does. Only the creator can change it (403 otherwise). The preview URL works either way."},"wildcard":{"type":"boolean","description":"true: the link also previews every subdomain of targetDomain as <sub>---<alias>.<preview domain> (Pro plan and up; 402 PLAN_REQUIRED otherwise). false turns it off on any plan.","example":true},"spaceId":{"type":"string","format":"uuid","nullable":true,"description":"Move the link to this space of the workspace (404 SPACE_NOT_FOUND otherwise); null takes it out of its space.","example":"3fa85f64-5717-4562-b3fc-2c963f66afa6"}}},"QaStagingAuthInput":{"type":"object","properties":{"type":{"type":"string","enum":["none","basic","header","cookie"],"description":"none removes the credentials; basic = HTTP Basic auth; header = one request header; cookie = one cookie. Sent only to the link's domain (and its www twin) on the new server."},"username":{"type":"string","maxLength":200,"description":"basic: the username (no \":\").","example":"staging"},"password":{"type":"string","maxLength":256,"writeOnly":true,"description":"basic: the password. Omit to keep the saved one (same username). Never returned."},"name":{"type":"string","maxLength":100,"description":"header: the header name (Host, Cookie, transport and forwarding headers are refused); cookie: the cookie name.","example":"X-Staging-Token"},"value":{"type":"string","maxLength":4096,"writeOnly":true,"description":"header / cookie: the value. Omit to keep the saved one (same name). Never returned."}},"required":["type"]},"V1CreateQaReportDto":{"type":"object","properties":{"maxPages":{"type":"number","nullable":true,"minimum":1,"maximum":200,"description":"Pages to crawl; capped by the plan (402 above it). null = the plan maximum.","example":25},"includePaths":{"maxItems":20,"description":"Only crawl discovered pages matching one of these root-relative globs (* = any characters except \"/\", ** = anything). The start page is always crawled.","example":["/blog/**"],"type":"array","items":{"type":"string"}},"excludePaths":{"maxItems":20,"description":"Never crawl discovered pages matching one of these globs.","example":["/cart/**","/wp-admin/**"],"type":"array","items":{"type":"string"}},"viewports":{"type":"string","enum":["desktop","mobile","both"],"description":"desktop (1280×800), mobile (390×844) or both (pages are revisited on mobile)."},"crawlSpeed":{"type":"string","enum":["gentle","normal","fast"],"description":"gentle: one page at a time with a pause; normal: two in parallel; fast: four in parallel (plan feature)."},"respectRobotsTxt":{"type":"boolean","description":"Skip pages robots.txt disallows for the DNSPeekQA user agent (default true). Only the site owner should turn this off."},"compareWithLive":{"type":"boolean","description":"Fetch the current public site for the old vs new server comparison (default true)."},"activeProbes":{"type":"boolean","description":"robots.txt, sitemap, 404, canonical redirect and TLS probes of the new server (default true)."},"rerunAfterDnsSwitch":{"type":"boolean","description":"Re-check the live domain automatically once DNS points at the new server (default true)."},"notifyOnComplete":{"type":"boolean","description":"Send the \"QA report ready / failed\" notification (default true)."},"stagingAuth":{"description":"Credentials for a password-protected staging site. Encrypted at rest, never returned, used only for the target site.","allOf":[{"$ref":"#/components/schemas/QaStagingAuthInput"}]}}},"V1QaReportLink":{"type":"object","properties":{"alias":{"type":"string","example":"shop-staging"}},"required":["alias"]},"V1QaCounts":{"type":"object","properties":{"total":{"type":"number","example":7},"critical":{"type":"number","example":0},"warning":{"type":"number","example":3},"info":{"type":"number","example":4},"pagesCrawled":{"type":"number","example":25},"pagesWithErrors":{"type":"number","example":0},"truncated":{"type":"boolean","description":"The crawl budget ran out before every discovered page was visited."}},"required":["total","critical","warning","info","pagesCrawled","pagesWithErrors","truncated"]},"V1QaStagingAuthUsed":{"type":"object","properties":{"type":{"type":"string","enum":["none","basic","header","cookie"]},"username":{"type":"string","description":"basic: the username (never the password).","example":"staging"},"name":{"type":"string","description":"header / cookie: its name (never the value).","example":"X-Staging-Token"}},"required":["type"]},"V1QaReportSettings":{"type":"object","properties":{"maxPages":{"type":"number","example":25,"description":"Page budget the crawl used (after the plan cap)."},"includePaths":{"example":["/blog/**"],"type":"array","items":{"type":"string"}},"excludePaths":{"example":["/cart/**"],"type":"array","items":{"type":"string"}},"viewports":{"type":"string","enum":["desktop","mobile","both"]},"crawlSpeed":{"type":"string","enum":["gentle","normal","fast"]},"respectRobotsTxt":{"type":"boolean"},"compareWithLive":{"type":"boolean"},"activeProbes":{"type":"boolean"},"rerunAfterDnsSwitch":{"type":"boolean"},"notifyOnComplete":{"type":"boolean"},"stagingAuth":{"description":"Kind of staging credentials used; secrets are never returned.","allOf":[{"$ref":"#/components/schemas/V1QaStagingAuthUsed"}]}},"required":["maxPages","includePaths","excludePaths","viewports","crawlSpeed","respectRobotsTxt","compareWithLive","activeProbes","rerunAfterDnsSwitch","notifyOnComplete","stagingAuth"]},"V1QaReport":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"link":{"$ref":"#/components/schemas/V1QaReportLink"},"status":{"type":"string","enum":["queued","running","completed","failed"],"description":"Poll until completed or failed (a run takes about 1–5 minutes)."},"score":{"type":"number","nullable":true,"minimum":0,"maximum":100,"example":92},"verdict":{"type":"string","nullable":true,"enum":["ready","ready_with_warnings","not_ready"],"description":"Completed reports: not_ready with any critical finding, ready_with_warnings with any warning, else ready."},"counts":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/V1QaCounts"}]},"error":{"type":"string","nullable":true,"description":"Why a failed run failed."},"createdAt":{"type":"string","format":"date-time"},"completedAt":{"type":"string","format":"date-time","nullable":true},"reportUrl":{"type":"string","example":"https://dnspeek.link/qa/3fa85f64-5717-4562-b3fc-2c963f66afa6","description":"Full report in the dashboard (sign-in required)."},"shareUrl":{"type":"string","example":"https://dnspeek.link/r/Qm9vc3QtbWUtdXAtdGhlLXNoYXJlLXRva2VuLXZhbHVl","description":"Read-only report anyone with the link can open."},"settings":{"nullable":true,"description":"Run settings the crawl used (no secrets); null for reports created before run settings existed.","allOf":[{"$ref":"#/components/schemas/V1QaReportSettings"}]}},"required":["id","link","status","score","verdict","counts","error","createdAt","completedAt","reportUrl","shareUrl","settings"]},"V1QaReportPage":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/V1QaReport"}},"total":{"type":"number","example":3},"limit":{"type":"number","example":20},"nextCursor":{"type":"string","nullable":true}},"required":["items","total","limit","nextCursor"]},"V1Resolver":{"type":"object","properties":{"resolver":{"type":"string","example":"Cloudflare"},"location":{"type":"string","example":"Global (anycast)"},"address":{"type":"string","example":"1.1.1.1"},"answers":{"example":["203.0.113.10"],"type":"array","items":{"type":"string"}},"ttl":{"type":"number","nullable":true,"example":300},"matched":{"type":"boolean","description":"The resolver already returns expected_ip (matchMode `network`: only addresses of that CDN)."},"cdn":{"type":"string","enum":["cloudflare"],"description":"Set when every address the resolver returned belongs to this CDN."},"error":{"type":"string","example":"timeout"}},"required":["resolver","location","address","answers","ttl","matched"]},"V1DnsPropagation":{"type":"object","properties":{"domain":{"type":"string","example":"shop.example.com"},"expectedIp":{"type":"string","example":"203.0.113.10"},"recordType":{"type":"string","enum":["A","AAAA"]},"percent":{"type":"number","example":83,"description":"Share of answering resolvers that return expected_ip."},"matched":{"type":"number","example":10},"answered":{"type":"number","example":12,"description":"Resolvers that answered without error (the percent denominator)."},"total":{"type":"number","example":12},"maxTtl":{"type":"number","nullable":true,"example":3600,"description":"Highest TTL seen: caches may keep the old IP this many seconds."},"propagated":{"type":"boolean","description":"percent ≥ thresholdPercent with at least half of the resolvers answering."},"thresholdPercent":{"type":"number","example":90},"matchMode":{"type":"string","enum":["exact","network"],"description":"`network` when expected_ip is a CDN edge (Cloudflare): edges differ per region, so a resolver matches when every address it returns is on that CDN. `exact` otherwise."},"cdn":{"type":"string","enum":["cloudflare"],"nullable":true,"description":"The CDN of expected_ip, or the CDN the domain is proxied through."},"proxied":{"type":"boolean","description":"expected_ip is an origin server, but every resolver returns the CDN's addresses: the domain is behind the CDN proxy, so DNS cannot show a change of origin and percent stays 0."},"checkedAt":{"type":"string","format":"date-time"},"resolvers":{"type":"array","items":{"$ref":"#/components/schemas/V1Resolver"}},"cached":{"type":"boolean","description":"Served from the 60-second cache of the same domain and IP."}},"required":["domain","expectedIp","recordType","percent","matched","answered","total","maxTtl","propagated","thresholdPercent","matchMode","cdn","proxied","checkedAt","resolvers","cached"]}}},"externalDocs":{"description":"Guide and examples","url":"https://dnspeek.link/docs/api"}}