whoami
GET /api/v1/whoami
The user behind the token, their plan, what is left of every plan limit (limits: per day or month, or held at once) and what the token may do.
Scope: any
{
"data": {
"user": {
"id": 1,
"name": "Ada",
"email": "ada@example.com"
},
"plan": {
"names": [
"Pro"
],
"features": [
"api_access",
"search_insights"
]
},
"limits": {
"keyword_lookups": {
"label": "Keyword lookups",
"kind": "metered",
"window": "day",
"limit": 20,
"used": 4,
"remaining": 16,
"resets_at": "2026-10-20T00:00:00+00:00"
},
"tracked_keywords": {
"label": "Tracked keywords",
"kind": "capacity",
"window": null,
"limit": 150,
"used": 12,
"remaining": 138,
"resets_at": null
}
},
"token": {
"name": "Claude Code",
"scopes": [
"read:seo"
],
"expires_at": null,
"last_used_at": "2026-10-17T09:00:00+00:00"
}
}
}
list_sites
GET /api/v1/sites
Your SEO sites with Search Console connection status and the latest audit.
Scope: read:seo
{
"data": [
{
"host": "example.com",
"sources": [
"business",
"gsc"
],
"business": {
"id": 3,
"name": "Example"
},
"search_console": {
"connected": true,
"property": "sc-domain:example.com",
"tracked": true,
"synced_at": "2026-10-17T03:00:00+00:00",
"error": null
},
"latest_audit": {
"id": 41,
"score": 82,
"finished_at": "2026-10-15T10:00:00+00:00"
}
}
],
"meta": {
"total": 1
}
}
search_console_summary
GET /api/v1/sites/{site}/search-console/summary
Clicks, impressions, CTR and average position for a period, the previous period and the daily series. Served from synced data; with a range of 3 months or more, from/to, compare, search_type or filters it is a live Search Console query (cached for an hour).
Scope: read:seo
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
range | query | string | Period ending yesterday. Default 28d. 3m and longer are live from Google. One of: 7d, 28d, 90d, 3m, 6m, 12m, 16m. |
from | query | string | With to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour). |
to | query | string | End of the custom date range (YYYY-MM-DD), not in the future. |
compare | query | string | Compare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year. |
search_type | query | string | Search Console search type (live). Default web. One of: web, image, video, news, discover, googleNews. |
filters | query | array | Up to 8 conditions, all must match (live): a list of {"dimension": query|page|country|device|appearance, "operator": "contains|not_contains|equals|not_equals|regex|not_regex", "value"}. Regular expressions use RE2 syntax. In Search Console country is a 3-letter code (deu) and device is desktop, mobile or tablet. |
{
"data": {
"site": "example.com",
"property": "sc-domain:example.com",
"range": {
"key": "28d",
"days": 28,
"from": "2026-09-18",
"to": "2026-10-15"
},
"totals": {
"clicks": 420,
"impressions": 9800,
"ctr": 4.29,
"position": 11.4
},
"previous_period": {
"clicks": 380,
"impressions": 9100,
"ctr": 4.18,
"position": 12
},
"daily": [
{
"date": "2026-10-15",
"clicks": 15,
"impressions": 350,
"ctr": 4.29,
"position": 11.1
}
]
},
"meta": {
"synced_at": "2026-10-17T03:00:00+00:00",
"source": "synced_search_console"
}
}
search_console_queries
GET /api/v1/sites/{site}/search-console/queries
Search queries of the last 28 days (the synced snapshot), sortable and filterable. With from/to, range, compare, search_type, dimension or filters it is a live Search Console query (up to 25,000 rows, cached for an hour).
Scope: read:seo
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
range | query | string | Period ending yesterday. Default 28d. 3m and longer are live from Google. One of: 7d, 28d, 3m, 6m, 12m, 16m. |
from | query | string | With to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour). |
to | query | string | End of the custom date range (YYYY-MM-DD), not in the future. |
compare | query | string | Compare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year. |
search_type | query | string | Search Console search type (live). Default web. One of: web, image, video, news, discover, googleNews. |
dimension | query | string | Group the rows by this dimension instead of the default (live). One of: query, page, country, device, date, appearance. |
filters | query | array | Up to 8 conditions, all must match (live): a list of {"dimension": query|page|country|device|appearance, "operator": "contains|not_contains|equals|not_equals|regex|not_regex", "value"}. Regular expressions use RE2 syntax. In Search Console country is a 3-letter code (deu) and device is desktop, mobile or tablet. |
filter | query | string | Keep queries containing this text. |
sort | query | string | Default clicks. Position sorts best first. One of: clicks, impressions, ctr, position. |
limit | query | integer | Page size, 1 to 200. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"value": "best seo tools",
"clicks": 12,
"impressions": 340,
"ctr": 3.53,
"position": 6.2
}
],
"meta": {
"next_cursor": null,
"limit": 50,
"total": 1,
"site": "example.com",
"dimension": "query",
"window": "last_28_days",
"sort": "clicks",
"synced_at": "2026-10-17T03:00:00+00:00"
}
}
search_console_pages
GET /api/v1/sites/{site}/search-console/pages
Pages of the last 28 days (the synced snapshot), sortable and filterable. With from/to, range, compare, search_type, dimension or filters it is a live Search Console query (up to 25,000 rows, cached for an hour).
Scope: read:seo
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
range | query | string | Period ending yesterday. Default 28d. 3m and longer are live from Google. One of: 7d, 28d, 3m, 6m, 12m, 16m. |
from | query | string | With to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour). |
to | query | string | End of the custom date range (YYYY-MM-DD), not in the future. |
compare | query | string | Compare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year. |
search_type | query | string | Search Console search type (live). Default web. One of: web, image, video, news, discover, googleNews. |
dimension | query | string | Group the rows by this dimension instead of the default (live). One of: query, page, country, device, date, appearance. |
filters | query | array | Up to 8 conditions, all must match (live): a list of {"dimension": query|page|country|device|appearance, "operator": "contains|not_contains|equals|not_equals|regex|not_regex", "value"}. Regular expressions use RE2 syntax. In Search Console country is a 3-letter code (deu) and device is desktop, mobile or tablet. |
filter | query | string | Keep page URLs containing this text. |
sort | query | string | Default clicks. Position sorts best first. One of: clicks, impressions, ctr, position. |
limit | query | integer | Page size, 1 to 200. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"value": "https://example.com/blog/guide",
"clicks": 12,
"impressions": 340,
"ctr": 3.53,
"position": 6.2
}
],
"meta": {
"next_cursor": null,
"limit": 50,
"total": 1,
"site": "example.com",
"dimension": "page",
"window": "last_28_days",
"sort": "clicks",
"synced_at": "2026-10-17T03:00:00+00:00"
}
}
search_opportunities
GET /api/v1/sites/{site}/search-console/opportunities
Queries worth acting on: striking distance (position 4 to 20) and low click-through in the top 5.
Scope: read:seo
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
{
"data": {
"site": "example.com",
"striking_distance": [
{
"value": "best seo tools",
"clicks": 12,
"impressions": 340,
"ctr": 3.53,
"position": 6.2
}
],
"low_ctr": []
},
"meta": {
"window": "last_28_days",
"synced_at": "2026-10-17T03:00:00+00:00"
}
}
ga4_summary
GET /api/v1/sites/{site}/ga4/summary
Google Analytics (GA4) sessions, users, engaged sessions and key events for a period, the previous period, the daily series, countries and devices. Served from synced data of the property you chose for the site; with a longer or custom range, compare, metrics or filters it is a live Data API report (cached for an hour).
Scope: read:analytics
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
range | query | string | Period ending yesterday. Default 28d. 3m and longer are live from Google. One of: 28d, 90d, 3m, 6m, 12m, 16m. |
from | query | string | With to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour). |
to | query | string | End of the custom date range (YYYY-MM-DD), not in the future. |
compare | query | string | Compare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year. |
metrics | query | array | GA4 metrics to return (live): sessions, totalUsers, newUsers, engagedSessions, engagementRate, bounceRate, screenPageViews, eventCount, keyEvents, sessionKeyEventRate, averageSessionDuration, userEngagementDuration. Rates are percentages. |
filters | query | array | Up to 8 conditions, all must match (live): a list of {"dimension": a GA4 dimension such as pagePath, sessionSource, country, deviceCategory, eventName, "operator": "contains|not_contains|equals|not_equals|regex|not_regex", "value"}. Regular expressions use RE2 syntax. In Search Console country is a 3-letter code (deu) and device is desktop, mobile or tablet. |
{
"data": {
"site": "example.com",
"property": "properties/123456789",
"range": {
"key": "28d",
"days": 28,
"through": "2026-10-16"
},
"totals": {
"sessions": 5200,
"users": 3900,
"engaged_sessions": 3100,
"engagement_rate": 59.6,
"key_events": 140
},
"previous_period": {
"sessions": 4800,
"users": null,
"engaged_sessions": 2800,
"engagement_rate": 58.3,
"key_events": 120
},
"daily": [
{
"date": "2026-10-16",
"sessions": 180,
"users": 150,
"engaged_sessions": 110,
"key_events": 5
}
],
"countries": [
{
"value": "United States",
"sessions": 1200,
"users": 900,
"engaged_sessions": 800,
"engagement_rate": 66.7,
"key_events": 30
}
],
"devices": [
{
"value": "mobile",
"sessions": 1200,
"users": 900,
"engaged_sessions": 800,
"engagement_rate": 66.7,
"key_events": 30
}
]
},
"meta": {
"synced_at": "2026-10-17T03:45:00+00:00",
"source": "synced_google_analytics"
}
}
ga4_pages
GET /api/v1/sites/{site}/ga4/pages
Top pages by Google Analytics sessions for 28 or 90 days (the synced snapshot), sortable and filterable. With a longer or custom range, compare, dimension, metrics or filters it is a live Data API report (cached for an hour).
Scope: read:analytics
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
range | query | string | Period ending yesterday. Default 28d. 3m and longer are live from Google. One of: 28d, 90d, 3m, 6m, 12m, 16m. |
from | query | string | With to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour). |
to | query | string | End of the custom date range (YYYY-MM-DD), not in the future. |
compare | query | string | Compare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year. |
dimension | query | string | Group the rows by this GA4 dimension instead of the default (live), e.g. country, deviceCategory, sessionDefaultChannelGroup, eventName, landingPage, date. |
metrics | query | array | GA4 metrics to return (live): sessions, totalUsers, newUsers, engagedSessions, engagementRate, bounceRate, screenPageViews, eventCount, keyEvents, sessionKeyEventRate, averageSessionDuration, userEngagementDuration. Rates are percentages. |
filters | query | array | Up to 8 conditions, all must match (live): a list of {"dimension": a GA4 dimension such as pagePath, sessionSource, country, deviceCategory, eventName, "operator": "contains|not_contains|equals|not_equals|regex|not_regex", "value"}. Regular expressions use RE2 syntax. In Search Console country is a 3-letter code (deu) and device is desktop, mobile or tablet. |
filter | query | string | Keep page paths containing this text. |
sort | query | string | Default sessions. One of: sessions, users, engaged_sessions, key_events. |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"value": "/blog/guide",
"sessions": 1200,
"users": 900,
"engaged_sessions": 800,
"engagement_rate": 66.7,
"key_events": 30
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"site": "example.com",
"dimension": "page_path",
"range": "28d",
"sort": "sessions",
"synced_at": "2026-10-17T03:45:00+00:00"
}
}
ga4_sources
GET /api/v1/sites/{site}/ga4/sources
Traffic sources (source / medium, e.g. google / organic) by Google Analytics sessions for 28 or 90 days (the synced snapshot), sortable and filterable. With a longer or custom range, compare, dimension, metrics or filters it is a live Data API report (cached for an hour).
Scope: read:analytics
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
range | query | string | Period ending yesterday. Default 28d. 3m and longer are live from Google. One of: 28d, 90d, 3m, 6m, 12m, 16m. |
from | query | string | With to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour). |
to | query | string | End of the custom date range (YYYY-MM-DD), not in the future. |
compare | query | string | Compare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year. |
dimension | query | string | Group the rows by this GA4 dimension instead of the default (live), e.g. country, deviceCategory, sessionDefaultChannelGroup, eventName, landingPage, date. |
metrics | query | array | GA4 metrics to return (live): sessions, totalUsers, newUsers, engagedSessions, engagementRate, bounceRate, screenPageViews, eventCount, keyEvents, sessionKeyEventRate, averageSessionDuration, userEngagementDuration. Rates are percentages. |
filters | query | array | Up to 8 conditions, all must match (live): a list of {"dimension": a GA4 dimension such as pagePath, sessionSource, country, deviceCategory, eventName, "operator": "contains|not_contains|equals|not_equals|regex|not_regex", "value"}. Regular expressions use RE2 syntax. In Search Console country is a 3-letter code (deu) and device is desktop, mobile or tablet. |
filter | query | string | Keep sources containing this text. |
sort | query | string | Default sessions. One of: sessions, users, engaged_sessions, key_events. |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"value": "google / organic",
"sessions": 1200,
"users": 900,
"engaged_sessions": 800,
"engagement_rate": 66.7,
"key_events": 30
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"site": "example.com",
"dimension": "source_medium",
"range": "28d",
"sort": "sessions",
"synced_at": "2026-10-17T03:45:00+00:00"
}
}
list_audits
GET /api/v1/sites/{site}/audits
Site audits of one site, newest first.
Scope: read:seo
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"id": 41,
"host": "example.com",
"start_url": "https://example.com/",
"status": "done",
"score": 82,
"pages_crawled": 120,
"counts": {
"error": 2,
"warning": 9,
"notice": 14
},
"created_at": "2026-10-15T09:55:00+00:00",
"finished_at": "2026-10-15T10:00:00+00:00"
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"site": "example.com"
}
}
get_audit
GET /api/v1/audits/{id}
One audit: score with its health read (grade A–F, the fixes that reach the next grade and a 0–100 score per area: technical, onpage, speed, ai, ux), issues with the points each costs (impact), their area, example pages and fixes, what changed since the previous run, the pages with the most problems and, when measured, Google PageSpeed lab and field (Chrome UX Report) data for the start page, with the detailed audits under speed.detailed (same shape as pagespeed_check).
Scope: read:seo
| Parameter | In | Type | Description |
id * | path | integer | Audit id (see list_audits). |
{
"data": {
"id": 41,
"host": "example.com",
"status": "done",
"score": 82,
"health": {
"grade": "B",
"next": {
"grade": "A",
"points": 8,
"fixes": 2
},
"areas": [
{
"key": "technical",
"score": 96,
"issues": 2
},
{
"key": "onpage",
"score": 88,
"issues": 3
},
{
"key": "speed",
"score": 100,
"issues": 0
},
{
"key": "ai",
"score": 94,
"issues": 1
},
{
"key": "ux",
"score": 100,
"issues": 0
}
]
},
"issues": [
{
"code": "missing_meta_description",
"severity": "warning",
"message": "Missing meta description",
"pages": 9,
"area": "onpage",
"impact": 1,
"examples": [
"https://example.com/a"
],
"why": null,
"fix": null
}
],
"action_plan": [
{
"status": "new",
"severity": "warning",
"message": "Missing meta description",
"pages": 9
}
],
"speed": {
"strategy": "mobile",
"score": 71,
"lab": [
{
"id": "largest-contentful-paint",
"label": "Largest Contentful Paint",
"value": "3.2 s",
"score": 0.5
}
],
"field": {
"scope": "origin",
"overall": "AVERAGE",
"metrics": [
{
"key": "LARGEST_CONTENTFUL_PAINT_MS",
"label": "Largest Contentful Paint",
"value": "2.3 s",
"category": "FAST"
}
]
},
"measured_at": "2026-10-15T10:00:00.000Z"
},
"pages": []
}
}
pagespeed_check
GET /api/v1/pagespeed
Run Google PageSpeed on one page of your own sites and get everything needed to fix the code: score, lab and field (Chrome UX Report) metrics and, for each failing audit, the resources to change (render-blocking scripts and styles, unused JS and CSS with URLs and bytes, oversized images, fonts, LCP element, layout-shift elements, long tasks, third parties, cache lifetimes, redirects, server response time). Needs the site-audit plan feature; 30 fresh runs an hour and 100 a day (answers are cached 10 minutes and do not count).
Scope: read:seo
| Parameter | In | Type | Description |
url * | query | string | Full page address on one of your sites (see list_sites), e.g. https://example.com/pricing. |
strategy | query | string | Device: mobile (default) or desktop. One of: mobile, desktop. |
{
"data": {
"url": "https://example.com/",
"fetched_at": "2026-10-19T09:00:00.000Z",
"final_url": "https://example.com/",
"strategy": "mobile",
"score": 62,
"lab": [
{
"id": "largest-contentful-paint",
"label": "Largest Contentful Paint",
"value": "4.1 s",
"score": 0.4,
"numeric_value": 4100
}
],
"field": null,
"truncated": false,
"audits": [
{
"id": "render-blocking-resources",
"title": "Eliminate render-blocking resources",
"description": "Resources are blocking the first paint of your page.",
"score": 0.3,
"display_value": "Potential savings of 1,200 ms",
"savings_ms": 1200,
"savings_bytes": 0,
"items_total": 1,
"items": [
{
"url": "https://example.com/assets/app.css",
"totalBytes": 48000,
"wastedMs": 1200
}
]
}
]
},
"meta": {
"site": "example.com",
"strategy": "mobile",
"cached": false
}
}
list_products
GET /api/v1/products
Your Discover projects.
Scope: read:products
| Parameter | In | Type | Description |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"slug": "lumen-notes",
"name": "Lumen Notes",
"website_url": "https://lumen.example",
"short_description": "Notes that think.",
"status": "published",
"claim_status": "claimed",
"category": "productivity",
"logo_url": null,
"public_url": "https://woodander.com/products/lumen-notes",
"published_at": "2026-09-01T08:00:00+00:00"
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1
}
}
get_product
GET /api/v1/products/{slug}
One of your projects with its full description.
Scope: read:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see list_products). |
{
"data": {
"slug": "lumen-notes",
"name": "Lumen Notes",
"description": "Long text",
"pricing_model": "freemium",
"platforms": [
"web"
],
"status": "published"
}
}
product_analytics
GET /api/v1/products/{slug}/analytics
Views, outbound clicks, visitors, leads, daily series, referrers and campaigns. Ranges over 30 days need the advanced analytics plan feature.
Scope: read:analytics
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see list_products). |
range | query | string | Period. Default 30d. One of: 7d, 30d, 90d, 365d. |
{
"data": {
"product": "lumen-notes",
"range": {
"days": 30,
"from": "2026-09-18",
"to": "2026-10-17"
},
"totals": {
"views": 900,
"clicks": 120,
"visitors": 640,
"ctr": 13.3,
"leads": 4
},
"daily": [
{
"date": "2026-10-17",
"views": 30,
"clicks": 4
}
],
"referrers": [
{
"name": "Direct",
"views": 400
}
],
"campaigns": []
},
"meta": {
"range_limited": false
}
}
list_articles
GET /api/v1/articles
Your written pieces: Woodander Media articles, posts for your own site and Content Studio drafts, newest first.
Scope: read:media
| Parameter | In | Type | Description |
status | query | string | Keep one status, e.g. draft, in_review, published. |
type | query | string | Keep one kind. One of: media, site_post, seo_content. |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"id": "media-12",
"type": "media",
"title": "How we ship",
"status": "published",
"excerpt": "Short.",
"locale": "en",
"slug": "how-we-ship",
"url": "https://woodander.com/media/how-we-ship",
"is_sponsored": false,
"product": null,
"site": null,
"published_at": "2026-10-01T08:00:00+00:00",
"updated_at": "2026-10-02T08:00:00+00:00"
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"status": null,
"type": null
}
}
get_article
GET /api/v1/articles/{id}
One article with its Markdown body (the latest working draft while it is being edited).
Scope: read:media
| Parameter | In | Type | Description |
id * | path | string | Article id such as media-12, site_post-5 or seo_content-3 (see list_articles). |
{
"data": {
"id": "media-12",
"type": "media",
"title": "How we ship",
"status": "draft",
"body_markdown": "# Heading\n\nText."
}
}
gtm_list_containers
GET /api/v1/gtm/containers
Your Google Tag Manager accounts and containers, which site each one is chosen for, its mode, and the Tag Manager permissions you gave Woodander.
Scope: read:gtm
{
"data": [
{
"public_id": "GTM-AB12CD",
"name": "example.com",
"account": "Example",
"site": "example.com",
"mode": "hybrid",
"usage": [
"web"
]
}
],
"meta": {
"total": 1,
"permissions": {
"read": true,
"edit": true,
"publish": false
}
}
}
gtm_list_workspaces
GET /api/v1/sites/{site}/gtm/workspaces
The workspaces of the site's container and the one Woodander reads (its own "Woodander" workspace when it exists, else the Default Workspace).
Scope: read:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
{
"data": {
"site": "example.com",
"container": "GTM-AB12CD",
"workspaces": [
{
"id": "7",
"name": "Default Workspace"
},
{
"id": "9",
"name": "Woodander"
}
],
"in_use": {
"id": "9",
"name": "Woodander",
"fingerprint": "1697623",
"is_woodander": true
}
},
"meta": {
"source": "gtm_snapshot"
}
}
gtm_get_container
GET /api/v1/sites/{site}/gtm/container
The container snapshot: tags, triggers and variables in plain words, enabled built-in variables and the versions. Served from a cache keyed by the workspace fingerprint; refresh=true asks Google again.
Scope: read:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
refresh | query | boolean | Read from Google instead of the cached snapshot (uses the shared quota). |
{
"data": {
"site": "example.com",
"mode": "hybrid",
"container": {
"public_id": "GTM-AB12CD",
"name": "example.com"
},
"workspace": {
"id": "9",
"name": "Woodander",
"is_woodander": true
},
"tags": [
{
"id": "3",
"name": "Google tag - G-ABC123",
"type": "googtag",
"details": [
{
"label": "Type",
"value": "Google tag"
},
{
"label": "ID",
"value": "G-ABC123"
},
{
"label": "Fires on",
"value": "All Pages"
}
],
"firing_trigger_ids": [
"2147479553"
],
"paused": false
}
],
"triggers": [],
"variables": [],
"built_in_variables": [
"Page URL",
"Event"
],
"versions": [
{
"id": "4",
"name": "Launch",
"tags": 3,
"deleted": false
}
],
"fetched_at": "2026-10-18T10:00:00+00:00"
},
"meta": {
"stale": false,
"source": "gtm_snapshot"
}
}
gtm_analyze_tracking
GET /api/v1/sites/{site}/gtm/analysis
Compares the container with a tracking plan (default page_view, sign_up, begin_checkout, purchase): missing events, duplicates (two Google tags or GA4 configurations with the same measurement id, an event sent twice), tags without or with broken triggers, undefined variables, unused triggers, and sign-ups or purchases sent on a click.
Scope: read:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
events | query | string | Your plan: GA4 event names separated by commas, e.g. page_view,sign_up,test_started,purchase. |
{
"data": {
"site": "example.com",
"plan": [
{
"event": "page_view",
"status": "ok",
"tags": [
"Google tag - G-ABC123"
]
},
{
"event": "sign_up",
"status": "missing",
"tags": []
}
],
"issues": [
{
"code": "missing_events",
"severity": "warning",
"message": "Events of the tracking plan that no tag sends.",
"items": [
"sign_up"
]
}
],
"counts": {
"tags": 1,
"triggers": 0,
"variables": 0
}
},
"meta": {
"source": "gtm_snapshot"
}
}
gtm_scan_site
GET /api/v1/sites/{site}/gtm/scan
Reads one live page of your site and finds tags hard-coded in its HTML: gtag.js / GA4 (G-), Google Ads (AW-), Meta Pixel, Microsoft Clarity, Hotjar, LinkedIn Insight, TikTok Pixel and an existing GTM snippet. Changes nothing.
Scope: read:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
url | query | string | A page of this site (path or full https URL). Default: the start page. |
{
"data": {
"site": "example.com",
"url": "https://example.com/",
"status": 200,
"found": [
{
"vendor": "ga4",
"label": "Google Analytics 4 (gtag.js)",
"id": "G-ABC123",
"evidence": "gtag.js"
},
{
"vendor": "meta_pixel",
"label": "Meta Pixel",
"id": "1234567890",
"evidence": "fbq init"
}
],
"gtm_snippets": [
"GTM-AB12CD"
]
},
"meta": {
"changes_site": false
}
}
gtm_plan_migration
GET /api/v1/sites/{site}/gtm/migration-plan
Site → GTM: the tags that would replace the snippets hard-coded on the page, as ready inputs for gtm_create_tag, plus warnings. Never publishes and never edits the site: remove the snippets in the site's code and publish together with that deploy, or everything is counted twice.
Scope: read:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
url | query | string | A page of this site. Default: the start page. |
{
"data": {
"site": "example.com",
"container": "GTM-AB12CD",
"scan": {
"url": "https://example.com/",
"found": []
},
"steps": [
{
"action": "gtm.create_tag",
"vendor": "meta_pixel",
"input": {
"name": "Meta Pixel - base code",
"type": "html",
"html": "<script>...</script>",
"firing_trigger_ids": [
"2147479553"
]
},
"reason": "Replaces the hard-coded Meta Pixel (1234567890)."
}
],
"already_in_gtm": [
"Google tag - G-ABC123"
],
"manual": [],
"warnings": [
"While a snippet stays in the site’s code and the same tag is live in Tag Manager, every visit and event is counted twice. Publish the version only together with the deploy that removes the snippets from the site."
]
},
"meta": {
"publishes": false,
"source": "gtm_snapshot"
}
}
gtm_list_changes
GET /api/v1/sites/{site}/gtm/changes
The last 50 change requests for the site's container: proposals, changes waiting for the owner's confirmation, queued, done, failed and rejected ones, each with its diff.
Scope: read:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
{
"data": [
{
"id": 12,
"action": "gtm.create_tag",
"risk": "reversible",
"status": "pending",
"needs": "apply",
"summary": "Create tag “GA4 event - sign_up” (GA4 event)",
"diff": [
{
"op": "+",
"kind": "tag",
"name": "GA4 event - sign_up",
"details": [
"Type: GA4 event",
"ID: G-ABC123",
"Event: sign_up",
"Fires on: CE - sign_up"
]
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/seo/tag-manager?site=example.com&change=12",
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
}
],
"meta": {
"total": 1,
"site": "example.com"
}
}
gtm_change_status
GET /api/v1/gtm/changes/{id}
One change request (or a queued read) by id: its status (pending, queued, running, done, failed, rejected), what it waits for and the result, e.g. the GTM version id.
Scope: read:gtm
| Parameter | In | Type | Description |
id * | path | integer | The id a change or a queued read returned. |
{
"data": {
"id": 12,
"action": "gtm.create_version",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Create version “sign_up tracking” from the Woodander workspace",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Create version “sign_up tracking” from the Woodander workspace",
"ids": {
"tag_id": "25"
}
},
"confirm_url": null,
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
gtm_create_tag
POST /api/v1/sites/{site}/gtm/tags
Create a tag in the "Woodander" workspace (never the live Default Workspace). A duplicate (same name, same Google tag id, same GA4 event to the same id) is refused. Manual mode: stored as a proposal the owner applies.
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
name * | body | string | Tag name, e.g. "GA4 event - sign_up". |
type * | body | string | googtag = Google tag (GA4 / Ads id), gaawe = GA4 event, html = Custom HTML, awct = Google Ads conversion, gclidw = Conversion Linker. One of: googtag, gaawe, html, awct, gclidw. |
google_tag_id | body | string | googtag: the id it loads, e.g. G-ABC123 or AW-123456. |
measurement_id | body | string | gaawe: the GA4 measurement id the event is sent to, e.g. G-ABC123. |
event_name | body | string | gaawe: the GA4 event name, e.g. sign_up, or one variable reference such as {{Event}} (a variable of the container or a built-in one) to send many events with one tag. |
event_parameters | body | array | gaawe: event parameters, a list of {"name", "value"} (value may be a {{Variable}}). |
html | body | string | html: the Custom HTML. |
conversion_id | body | string | awct: Google Ads conversion id. |
conversion_label | body | string | awct: Google Ads conversion label. |
firing_trigger_ids | body | array | Ids of the triggers that fire it (2147479553 = All Pages). One of firing_trigger_ids or firing_trigger_names is required to create. |
firing_trigger_names | body | array | Trigger names instead of ids, resolved when the change runs (use it for a trigger another change creates first). |
paused | body | boolean | Pause the tag. |
notes | body | string | Notes kept on the tag. |
{
"data": {
"id": 12,
"action": "gtm.create_tag",
"risk": "reversible",
"status": "pending",
"needs": "apply",
"summary": "Create tag “GA4 event - sign_up” (GA4 event)",
"diff": [
{
"op": "+",
"kind": "tag",
"name": "GA4 event - sign_up",
"details": [
"Type: GA4 event",
"ID: G-ABC123",
"Event: sign_up",
"Fires on: CE - sign_up"
]
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/seo/tag-manager?site=example.com&change=12",
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "proposed",
"mode": "manual",
"hint": null
}
}
gtm_update_tag
PATCH /api/v1/sites/{site}/gtm/tags/{id}
Update a tag in the "Woodander" workspace. Fields you send replace those of the tag; the others stay.
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
id * | path | string | GTM tag id (numeric, see gtm_get_container). |
name | body | string | Tag name, e.g. "GA4 event - sign_up". |
google_tag_id | body | string | googtag: the id it loads, e.g. G-ABC123 or AW-123456. |
measurement_id | body | string | gaawe: the GA4 measurement id the event is sent to, e.g. G-ABC123. |
event_name | body | string | gaawe: the GA4 event name, e.g. sign_up, or one variable reference such as {{Event}} (a variable of the container or a built-in one) to send many events with one tag. |
event_parameters | body | array | gaawe: event parameters, a list of {"name", "value"} (value may be a {{Variable}}). |
html | body | string | html: the Custom HTML. |
conversion_id | body | string | awct: Google Ads conversion id. |
conversion_label | body | string | awct: Google Ads conversion label. |
firing_trigger_ids | body | array | Ids of the triggers that fire it (2147479553 = All Pages). One of firing_trigger_ids or firing_trigger_names is required to create. |
firing_trigger_names | body | array | Trigger names instead of ids, resolved when the change runs (use it for a trigger another change creates first). |
paused | body | boolean | Pause the tag. |
notes | body | string | Notes kept on the tag. |
{
"data": {
"id": 12,
"action": "gtm.update_tag",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Update tag #25",
"diff": [
{
"op": "~",
"kind": "tag",
"name": "GA4 event - sign_up",
"details": [
"Event: signup → sign_up"
]
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Update tag #25",
"ids": {
"tag_id": "25"
}
},
"confirm_url": null,
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
gtm_delete_tag
DELETE /api/v1/sites/{site}/gtm/tags/{id}
Delete a tag in the "Woodander" workspace. Deleting always waits for the owner's confirmation in the Workspace (confirm_url), in every mode.
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
id * | path | string | GTM tag id (numeric, see gtm_get_container). |
{
"data": {
"id": 12,
"action": "gtm.delete_tag",
"risk": "destructive",
"status": "pending",
"needs": "confirm",
"summary": "Delete tag #25",
"diff": [
{
"op": "-",
"kind": "tag",
"name": "Old pixel",
"details": [
"Type: Custom HTML"
]
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/seo/tag-manager?site=example.com&change=12",
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "pending_confirmation",
"mode": "hybrid",
"hint": null
}
}
gtm_create_trigger
POST /api/v1/sites/{site}/gtm/triggers
Create a trigger in the "Woodander" workspace. A custom event trigger for an event that already has one is refused.
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
name * | body | string | Trigger name, e.g. "CE - sign_up". |
type * | body | string | Trigger type. One of: pageview, domReady, windowLoaded, customEvent, click, linkClick, formSubmission, historyChange. |
event_name | body | string | customEvent: the dataLayer event it listens for. |
filters | body | array | Conditions, a list of {"variable": "Page Path", "operator": "equals|contains|startsWith|endsWith|matchRegex", "value"}. |
notes | body | string | Notes kept on the trigger. |
{
"data": {
"id": 12,
"action": "gtm.create_trigger",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Create trigger “CE - sign_up” (Custom event)",
"diff": [
{
"op": "+",
"kind": "trigger",
"name": "CE - sign_up",
"details": [
"Type: Custom event",
"Event: sign_up"
]
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Create trigger “CE - sign_up” (Custom event)",
"ids": {
"tag_id": "25"
}
},
"confirm_url": null,
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
gtm_update_trigger
PATCH /api/v1/sites/{site}/gtm/triggers/{id}
Update a trigger in the "Woodander" workspace.
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
id * | path | string | GTM trigger id (numeric, see gtm_get_container). |
name | body | string | Trigger name, e.g. "CE - sign_up". |
event_name | body | string | customEvent: the dataLayer event it listens for. |
filters | body | array | Conditions, a list of {"variable": "Page Path", "operator": "equals|contains|startsWith|endsWith|matchRegex", "value"}. |
notes | body | string | Notes kept on the trigger. |
{
"data": {
"id": 12,
"action": "gtm.update_trigger",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Update trigger #31",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Update trigger #31",
"ids": {
"tag_id": "25"
}
},
"confirm_url": null,
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
gtm_create_variable
POST /api/v1/sites/{site}/gtm/variables
Create a variable in the "Woodander" workspace.
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
name * | body | string | Variable name. |
type * | body | string | v = data layer variable, c = constant, u = URL, k = 1st-party cookie, jsm = custom JavaScript. One of: v, c, u, k, jsm. |
data_layer_name | body | string | v: the dataLayer key, e.g. ecommerce.value. |
value | body | string | c: the constant value. |
component | body | string | u: the part of the URL. One of: URL, HOST, PATH, QUERY, FRAGMENT. |
cookie_name | body | string | k: the cookie name. |
javascript | body | string | jsm: function () { return ...; } |
notes | body | string | Notes kept on the variable. |
{
"data": {
"id": 12,
"action": "gtm.create_variable",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Create variable “DLV - value” (Data layer variable)",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Create variable “DLV - value” (Data layer variable)",
"ids": {
"tag_id": "25"
}
},
"confirm_url": null,
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
gtm_update_variable
PATCH /api/v1/sites/{site}/gtm/variables/{id}
Update a variable in the "Woodander" workspace.
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
id * | path | string | GTM variable id (numeric, see gtm_get_container). |
name | body | string | Variable name. |
data_layer_name | body | string | v: the dataLayer key, e.g. ecommerce.value. |
value | body | string | c: the constant value. |
component | body | string | u: the part of the URL. One of: URL, HOST, PATH, QUERY, FRAGMENT. |
cookie_name | body | string | k: the cookie name. |
javascript | body | string | jsm: function () { return ...; } |
notes | body | string | Notes kept on the variable. |
{
"data": {
"id": 12,
"action": "gtm.update_variable",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Update variable #40",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Update variable #40",
"ids": {
"tag_id": "25"
}
},
"confirm_url": null,
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
gtm_create_version
POST /api/v1/sites/{site}/gtm/versions
Turn the "Woodander" workspace into a container version. Not live: publishing is a separate step (gtm_publish_version). Google removes the workspace afterwards; the next change makes a new one.
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
name * | body | string | Version name. |
notes | body | string | What changed and why. |
{
"data": {
"id": 12,
"action": "gtm.create_version",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Create version “sign_up tracking” from the Woodander workspace",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Create version “sign_up tracking” from the Woodander workspace",
"ids": {
"tag_id": "25"
}
},
"confirm_url": null,
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
gtm_publish_version
POST /api/v1/sites/{site}/gtm/versions/{version_id}/publish
Make a version live. Guarded: in Manual and Hybrid it returns a pending confirmation (confirm_url) the owner gives in the Workspace; only in Autonomous mode, with a token that also holds publish:gtm and within the daily publish cap, it publishes at once (duplicate check, the previous version kept for rollback, an email to the owner).
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
version_id * | path | string | The version id (from gtm_create_version or gtm_get_container). |
{
"data": {
"id": 12,
"action": "gtm.publish_version",
"risk": "publish",
"status": "pending",
"needs": "confirm",
"summary": "Publish version 5",
"diff": [
{
"op": "~",
"kind": "live version",
"name": "version 5 “sign_up tracking”",
"details": [
"Becomes live on the site for every visitor"
]
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/seo/tag-manager?site=example.com&change=12",
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "pending_confirmation",
"mode": "hybrid",
"hint": null
}
}
gtm_rollback
POST /api/v1/sites/{site}/gtm/rollback
Republish the version that was live before the last publish through Woodander (or the version you name). Same guard as gtm_publish_version.
Scope: write:gtm
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers). |
version_id | body | string | A version to go back to. Default: the one live before the last publish. |
{
"data": {
"id": 12,
"action": "gtm.rollback",
"risk": "publish",
"status": "pending",
"needs": "confirm",
"summary": "Roll back to the version live before the last publish",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/seo/tag-manager?site=example.com&change=12",
"created_at": "2026-10-18T10:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "pending_confirmation",
"mode": "hybrid",
"hint": null
}
}
keyword_ideas
GET /api/v1/keywords/ideas
Keyword ideas with monthly search volume, competition (0 to 1), CPC in USD and a 12-month trend, for a seed keyword or (without seed) for your site. A new search uses one of your plan's daily keyword lookups (repeating it the same UTC day is free); meta.limits says what is left.
Scope: read:keywords
| Parameter | In | Type | Description |
site * | query | string | Host of one of your sites (see list_sites); the keywords belong to it and set the default location and language. |
seed | query | string | Up to 5 keywords separated by commas. Leave it out for keywords of the site itself. |
location | query | integer | Location code, e.g. 2840 United States, 2826 United Kingdom, 2276 Germany, 2724 Spain, 2804 Ukraine. Default: the site's market. |
language | query | string | Language code: en, de, es, uk, pl, fr, it, nl. Default: the site's language. |
{
"data": {
"site": "example.com",
"mode": "seed",
"seeds": [
"ceramic mugs"
],
"location": {
"code": 2840,
"name": "United States"
},
"language": {
"code": "en",
"name": "English"
},
"items": [
{
"keyword": "ceramic mugs",
"search_volume": 14800,
"competition": 0.92,
"competition_level": "HIGH",
"cpc_usd": 1.12,
"trend": [
12100,
12100,
14800,
14800,
18100,
22200,
14800,
12100,
12100,
14800,
14800,
14800
]
}
]
},
"meta": {
"cached": false,
"source": "dataforseo_labs",
"limits": {
"keyword_lookups": {
"kind": "metered",
"window": "day",
"limit": 20,
"used": 4,
"remaining": 16,
"resets_at": "2026-10-20T00:00:00+00:00"
}
}
}
}
tracked_keywords
GET /api/v1/sites/{site}/keywords/tracked
Keywords you track for a site with the latest Google position, change since the check before, best and worst, and the URL that ranks. Positions are organic ranks; null = not in the checked depth (or not checked yet).
Scope: read:keywords
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
limit | query | integer | Page size, 1 to 200. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"id": 7,
"keyword": "ceramic mugs",
"location": {
"code": 2840,
"name": "United States"
},
"language": "en",
"device": "desktop",
"position": 6,
"previous_position": 9,
"change": 3,
"best_position": 5,
"worst_position": 14,
"url": "https://example.com/mugs",
"last_checked_at": "2026-10-18T04:10:00+00:00",
"next_check_at": "2026-10-19T04:07:00+00:00"
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"site": "example.com",
"depth": 50,
"frequency": "daily",
"limits": {
"tracked_keywords": {
"kind": "capacity",
"window": null,
"limit": 150,
"used": 12,
"remaining": 138,
"resets_at": null
}
}
}
}
rank_history
GET /api/v1/sites/{site}/keywords/tracked/{id}/history
The position history of one tracked keyword: every finished check in the period, oldest first.
Scope: read:keywords
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
id * | path | integer | Tracked keyword id (see tracked_keywords). |
days | query | integer | Period in days, 1 to 365. Default 90. |
{
"data": {
"id": 7,
"keyword": "ceramic mugs",
"location": {
"code": 2840,
"name": "United States"
},
"language": "en",
"device": "desktop",
"position": 6,
"previous_position": 9,
"change": 3,
"best_position": 5,
"worst_position": 14,
"url": "https://example.com/mugs",
"last_checked_at": "2026-10-18T04:10:00+00:00",
"next_check_at": "2026-10-19T04:07:00+00:00",
"history": [
{
"checked_at": "2026-10-17T04:12:00+00:00",
"position": 9,
"url": "https://example.com/mugs"
},
{
"checked_at": "2026-10-18T04:10:00+00:00",
"position": 6,
"url": "https://example.com/mugs"
}
]
},
"meta": {
"site": "example.com",
"days": 90,
"depth": 50
}
}
ai_visibility
GET /api/v1/sites/{site}/ai-visibility
The prompts you track for a site (the questions your customers ask AI) with the latest answer of each AI platform: whether your brand was named, its place among the brands named, the competitors named, the sources cited and the change since the check before. meta.summary has the mention rate and average place, meta.share_of_voice the competitors across prompts. Stored checks, never a live call; needs AI visibility in your plan.
Scope: read:keywords
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites, e.g. example.com (see list_sites). |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"id": 3,
"prompt": "What are the best tools for mapping architecture?",
"location": "United States",
"language": "English",
"checked": "2 days ago",
"platforms": [
{
"platform": "chat_gpt",
"state": "done",
"mentioned": true,
"position": 4,
"trend": "same",
"model": "gpt-4.1-mini",
"checked_at": "2026-10-21T04:10:00+00:00",
"competitors": [
{
"name": "Alchetype",
"domain": "alchetype.co"
}
],
"sources": [
{
"domain": "example.com",
"url": "https://example.com/en"
}
],
"cited_as_source": true,
"answer_excerpt": "1. **Alchetype** ... 4. **Archemap** ..."
}
]
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"site": "example.com",
"frequency": "weekly",
"summary": {
"checks": 2,
"mentioned": 1,
"rate": 50,
"rateDelta": null,
"averagePosition": 4,
"positionDelta": null,
"prompts": 1,
"cited": 1
},
"share_of_voice": [
{
"name": "Alchetype",
"domain": "alchetype.co",
"mentions": 2,
"share": 100,
"you": false
}
],
"limits": {
"ai_visibility_prompts": {
"kind": "capacity",
"window": null,
"limit": 25,
"used": 1,
"remaining": 24,
"resets_at": null
}
}
}
}
discover_list_products
GET /api/v1/discover/products
Your Discover projects with the AI mode you set for each (manual, hybrid, autonomous) and how many changes wait for you.
Scope: read:products
| Parameter | In | Type | Description |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"slug": "lumen-notes",
"name": "Lumen Notes",
"website_url": "https://lumen.example",
"short_description": "Notes that think.",
"status": "published",
"claim_status": "claimed",
"category": "productivity",
"logo_url": null,
"public_url": "https://woodander.com/products/lumen-notes",
"published_at": "2026-09-01T08:00:00+00:00",
"mode": "manual",
"pending_changes": 1
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1
}
}
discover_product_page
GET /api/v1/discover/products/{slug}
The full page of one of your products: the live version, your working draft (with what still blocks a submit), the review state, every language and the Briefs left this month.
Scope: read:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
{
"data": {
"slug": "lumen-notes",
"name": "Lumen Notes",
"website_url": "https://lumen.example",
"short_description": "Notes that think.",
"status": "published",
"claim_status": "claimed",
"category": "productivity",
"logo_url": null,
"public_url": "https://woodander.com/products/lumen-notes",
"published_at": "2026-09-01T08:00:00+00:00",
"mode": "manual",
"source_locale": "en",
"live": {
"name": "Lumen Notes",
"website_url": "https://lumen.example",
"short_description": "Notes that think.",
"description": "Long text",
"highlights": [
"Backlinks"
],
"faq": [
{
"question": "Is there a free plan?",
"answer": "Yes."
}
],
"links": [
{
"label": "Docs",
"url": "https://lumen.example/docs"
}
],
"screenshots": [
{
"url": "https://woodander.com/storage/discover/1/screenshots/a.png",
"alt": "Editor"
}
],
"video_url": null,
"cta_label": null,
"pricing": "freemium",
"pricing_model": null,
"platforms": [
"web"
]
},
"draft": {
"name": "Lumen Notes",
"website_url": "https://lumen.example",
"short_description": "Notes that think.",
"description": "Long text",
"highlights": [
"Backlinks"
],
"faq": [
{
"question": "Is there a free plan?",
"answer": "Yes."
}
],
"links": [
{
"label": "Docs",
"url": "https://lumen.example/docs"
}
],
"screenshots": [
{
"url": "https://woodander.com/storage/discover/1/screenshots/a.png",
"alt": "Editor"
}
],
"video_url": null,
"cta_label": null,
"pricing": "freemium",
"pricing_model": null,
"platforms": [
"web"
],
"unsubmitted_edits": true,
"problems": [],
"edited_at": "2026-10-19T08:00:00+00:00"
},
"review": {
"status": "in_review",
"submitted_at": "2026-10-18T08:00:00+00:00"
},
"languages": [
{
"locale": "uk",
"status": "draft",
"short_description": "",
"description": "",
"highlights": [],
"faq": []
}
],
"newsroom": {
"briefs_left_this_month": 28,
"url": "https://woodander.com/newsroom/lumen-notes"
}
},
"meta": {
"how_changes_go_live": "Page edits go to your draft; ..."
}
}
discover_leads
GET /api/v1/discover/products/{slug}/leads
Leads from the lead form of one of your projects, newest first, with their status.
Scope: read:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
status | query | string | Only leads with this status. One of: new, contacted, archived. |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"id": 7,
"name": "Ada",
"email": "ada@example.com",
"company": "Analytical",
"message": "Do you have a team plan?",
"status": "new",
"referrer": null,
"created_at": "2026-10-18T10:00:00+00:00"
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"product": "lumen-notes"
}
}
discover_reviews
GET /api/v1/discover/products/{slug}/reviews
Published reviews of one of your projects with your replies, newest first.
Scope: read:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"id": 4,
"rating": 5,
"body": "Lovely tool for linked notes, the backlinks are great.",
"author": "Grace",
"reply": null,
"replied_at": null,
"created_at": "2026-10-17T10:00:00+00:00"
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"product": "lumen-notes"
}
}
discover_analytics
GET /api/v1/discover/products/{slug}/analytics
Views, outbound clicks, visitors, leads, daily series, referrers and campaigns of one of your projects (as product_analytics). Ranges over 30 days need the advanced analytics plan feature.
Scope: read:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
range | query | string | Period. Default 30d. One of: 7d, 30d, 90d, 365d. |
{
"data": {
"product": "lumen-notes",
"range": {
"days": 30,
"from": "2026-09-20",
"to": "2026-10-19"
},
"totals": {
"views": 900,
"clicks": 120,
"visitors": 640,
"ctr": 13.3,
"leads": 4
},
"daily": [],
"referrers": [],
"campaigns": []
},
"meta": {
"range_limited": false
}
}
discover_list_changes
GET /api/v1/discover/products/{slug}/changes
The last 50 change requests for one of your products: proposals and confirmations waiting for you, and what ran.
Scope: read:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
{
"data": [
{
"id": 31,
"action": "discover.update_page",
"risk": "reversible",
"status": "pending",
"needs": "apply",
"summary": "Update the draft of Lumen Notes (tagline)",
"diff": [
{
"op": "~",
"kind": "Tagline",
"name": "",
"details": [],
"before": "Notes that think.",
"after": "Notes that link themselves."
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/discover/products/lumen-notes/ai-changes?change=31",
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
}
],
"meta": {
"total": 1,
"product": "lumen-notes",
"mode": "manual"
}
}
discover_change_status
GET /api/v1/discover/changes/{id}
One change request by id: its status (pending, done, failed, rejected), what it waits for and the result.
Scope: read:products
| Parameter | In | Type | Description |
id * | path | integer | The id a change request returned. |
{
"data": {
"id": 31,
"action": "discover.post_brief",
"risk": "publish",
"status": "done",
"needs": null,
"summary": "Publish the Brief “Lumen 2.0 is out” in the Newsroom of Lumen Notes",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Brief published in your Newsroom.",
"brief_id": 12
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
discover_update_page
PATCH /api/v1/discover/products/{slug}/page
Change the tagline, description, highlights, FAQ or links of one of your projects. Same rules as the Workspace editor: it goes into your draft (or, with locale, that language's draft), never straight onto the live page; submit: true also sends the draft to Woodander editors, who approve it before the page changes. Fields you send replace those fields; the others stay. Manual mode: stored as a proposal the owner applies.
Scope: write:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
locale | body | string | A language edition other than the project's own, e.g. uk or de: edits that language's draft (not links, not submit). A published language is refused: the owner edits it under Languages. |
short_description | body | string | Tagline, up to 80 characters. |
description | body | string | What it does, 80 to 600 characters. |
highlights | body | array | Up to 6 short highlights. |
faq | body | array | Up to 10 entries {"question", "answer"}, both required. |
links | body | array | Up to 6 extra links {"label", "url"}. |
submit | body | boolean | Also send the draft to Woodander editors (needs a complete draft and a confirmed email). |
{
"data": {
"id": 31,
"action": "discover.update_page",
"risk": "reversible",
"status": "pending",
"needs": "apply",
"summary": "Update the draft of Lumen Notes (tagline)",
"diff": [
{
"op": "~",
"kind": "Tagline",
"name": "",
"details": [],
"before": "Notes that think.",
"after": "Notes that link themselves."
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/discover/products/lumen-notes/ai-changes?change=31",
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "proposed",
"mode": "manual",
"hint": null
}
}
discover_add_screenshot_from_url
POST /api/v1/discover/products/{slug}/screenshots
Add a screenshot to the draft of one of your projects from a public https image address (PNG, JPG or WebP, at least 1200 × 650, cropped to 16:10, up to 8 MB, at most 8 screenshots). Downloaded when it runs; the page shows it after the draft is submitted and approved.
Scope: write:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
url * | body | string | https address of the image (no redirects, public hosts only). |
alt | body | string | Alt text, up to 150 characters. |
{
"data": {
"id": 31,
"action": "discover.add_screenshot_from_url",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Add a screenshot to the draft of Lumen Notes",
"diff": [
{
"op": "+",
"kind": "Screenshot",
"name": "",
"details": [
"https://lumen.example/shot.png"
]
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Screenshot added to your draft.",
"draft_id": 3,
"path": "discover/1/screenshots/a.png"
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
discover_post_brief
POST /api/v1/discover/products/{slug}/briefs
Publish a Newsroom Brief for one of your products: short news, live at once, within the monthly quota (see discover_product_page). Public, so in Manual and Hybrid it waits for the owner's confirmation (confirm_url); in Autonomous it is published.
Scope: write:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
title * | body | string | Up to 120 characters. |
body * | body | string | 40 to 1200 characters, up to 3 paragraphs, no markup. |
source_url | body | string | A link to the source (http or https). |
{
"data": {
"id": 31,
"action": "discover.post_brief",
"risk": "publish",
"status": "pending",
"needs": "confirm",
"summary": "Publish the Brief “Lumen 2.0 is out” in the Newsroom of Lumen Notes",
"diff": [
{
"op": "+",
"kind": "Brief",
"name": "Lumen 2.0 is out",
"details": [],
"before": null,
"after": "Linked notes, a new editor and ..."
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/discover/products/lumen-notes/ai-changes?change=31",
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "pending_confirmation",
"mode": "hybrid",
"hint": null
}
}
discover_reply_review
POST /api/v1/discover/products/{slug}/reviews/{review_id}/reply
Publish your reply under a review of one of your projects (one reply per review; a new one replaces it). Public, so in Manual and Hybrid it waits for the owner's confirmation (confirm_url).
Scope: write:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
review_id * | path | integer | Review id (see discover_reviews). |
reply * | body | string | Up to 1000 characters. |
{
"data": {
"id": 31,
"action": "discover.reply_review",
"risk": "publish",
"status": "pending",
"needs": "confirm",
"summary": "Reply to the review by Grace (5★) of Lumen Notes",
"diff": [
{
"op": "+",
"kind": "Reply",
"name": "",
"details": [
"Review: Lovely tool ..."
],
"before": null,
"after": "Thank you, Grace!"
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/discover/products/lumen-notes/ai-changes?change=31",
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "pending_confirmation",
"mode": "hybrid",
"hint": null
}
}
discover_update_lead_status
PATCH /api/v1/discover/products/{slug}/leads/{lead_id}
Mark a lead of one of your projects new, contacted or archived.
Scope: write:products
| Parameter | In | Type | Description |
slug * | path | string | Slug of one of your projects (see discover_list_products). |
lead_id * | path | integer | Lead id (see discover_leads). |
status * | body | string | The new status. One of: new, contacted, archived. |
{
"data": {
"id": 31,
"action": "discover.update_lead_status",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Mark the lead from Ada as contacted",
"diff": [
{
"op": "~",
"kind": "Lead status",
"name": "Ada",
"details": [],
"before": "new",
"after": "contacted"
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Lead marked as contacted.",
"lead_id": 7
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"mode": "hybrid",
"hint": null
}
}
uptime_status
GET /api/v1/uptime
Your uptime monitors: state (up, down, unknown, paused), the last check (HTTP status, response time, reason when down), uptime percentages for 24 hours, 7 and 30 days, and the latest incidents. Empty when your plan has no uptime monitoring.
Scope: read:seo
| Parameter | In | Type | Description |
site | query | string | Keep one of your sites (host, see list_sites). |
{
"data": [
{
"site": "example.com",
"url": "https://example.com/",
"state": "up",
"active": true,
"since": "2026-10-12T08:00:00+00:00",
"last_checked_at": "2026-10-19T09:59:00+00:00",
"last_status_code": 200,
"last_response_ms": 142,
"last_error": null,
"uptime": {
"h24": 100,
"d7": 99.94,
"d30": 99.9
},
"incidents": [
{
"started_at": "2026-10-11T22:10:00+00:00",
"ended_at": "2026-10-11T22:19:00+00:00",
"minutes": 9,
"error": "status",
"status_code": 503
}
]
}
],
"meta": {
"total": 1,
"interval_minutes": 1,
"limit": 3
}
}
list_reports
GET /api/v1/reports
Your white-label PDF reports, newest first (status queued, done or failed), and your scheduled client reports with their recipients. download_url opens the PDF for the signed-in owner of the account for 30 minutes. Empty without white-label reports on your plan.
Scope: read:seo
| Parameter | In | Type | Description |
site | query | string | Keep one of your sites (host, see list_sites). |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"id": 12,
"site": "example.com",
"status": "done",
"source": "schedule",
"size_bytes": 148220,
"created_at": "2026-10-19T06:00:00+00:00",
"download_url": "https://woodander.com/workspace/seo/client-reports/12/download?signature=…"
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"schedules": [
{
"id": 3,
"site": "example.com",
"frequency": "monthly",
"active": true,
"recipients": 2,
"unsubscribed": 0
}
]
}
}
create_article_draft
POST /api/v1/articles/drafts
Create a private Media draft from Markdown in your name. It stays private: sending it to Woodander editors (with your rights statement) is yours in the editor, editors review every piece, and paid labels are never touched. Drafts an agent wrote are marked AI-assisted. What happens depends on the mode the owner set for this module in Settings → AI activity: Manual stores a proposal the owner applies in the Workspace (the answer has a confirm_url), Hybrid and Autonomous run it. Poll agent_change_status with the id.
Scope: write:media
| Parameter | In | Type | Description |
title * | body | string | Title, up to 160 characters. |
excerpt | body | string | Teaser, up to 300 characters. |
body_markdown * | body | string | The body in Markdown (headings, lists, links, images by https address). Unsafe markup is removed. |
locale * | body | string | Language code of the piece, e.g. en or uk. |
type | body | string | Format of the piece (default story). |
topic_id | body | integer | A Media topic id. |
related_product_id | body | integer | One of your Discover projects the piece is about. |
{
"data": {
"id": 41,
"action": "media.create_draft",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Create the draft “Notes on a quiet launch”",
"diff": [
{
"op": "+",
"kind": "Title",
"name": "",
"details": [
"Language: en"
],
"before": null,
"after": "Notes on a quiet launch"
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Draft saved. It is private until you send it to Woodander editors from the editor.",
"article_id": "media-12",
"editor_url": "https://woodander.com/media/edit/12"
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"module": "media",
"mode": "hybrid",
"hint": null
}
}
update_article_draft
PATCH /api/v1/articles/drafts/{id}
Change the title, teaser or Markdown body of the working draft of one of your Media pieces. What is with the editors or published stays as it is until you send the draft again. Fields you send replace those fields. What happens depends on the mode the owner set for this module in Settings → AI activity: Manual stores a proposal the owner applies in the Workspace (the answer has a confirm_url), Hybrid and Autonomous run it. Poll agent_change_status with the id.
Scope: write:media
| Parameter | In | Type | Description |
id * | path | string | Id of one of your Media drafts, e.g. media-12 (see list_articles). |
title | body | string | Title, up to 160 characters. |
excerpt | body | string | Teaser, up to 300 characters. |
body_markdown | body | string | The body in Markdown (headings, lists, links, images by https address). Unsafe markup is removed. |
{
"data": {
"id": 41,
"action": "media.update_draft",
"risk": "reversible",
"status": "pending",
"needs": "apply",
"summary": "Update the draft “Notes on a quiet launch” (title)",
"diff": [
{
"op": "~",
"kind": "Title",
"name": "",
"details": [],
"before": "Notes on a quiet launch",
"after": "Notes on a calm launch"
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/settings/ai-activity?module=media&change=41",
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "proposed",
"module": "media",
"mode": "manual",
"hint": null
}
}
run_audit
POST /api/v1/sites/{site}/audits
Start a site audit of one of your sites, within your plan (the free plan has one audit a month, and only one audit runs at a time). The audit runs in the background: read it with list_audits and get_audit. What happens depends on the mode the owner set for this module in Settings → AI activity: Manual stores a proposal the owner applies in the Workspace (the answer has a confirm_url), Hybrid and Autonomous run it. Poll agent_change_status with the id.
Scope: run:audits
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites (see list_sites). |
{
"data": {
"id": 41,
"action": "seo.run_audit",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Start a site audit of example.com",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Audit of example.com started. It runs in the background.",
"audit_id": 18
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"module": "seo",
"mode": "hybrid",
"hint": null
}
}
queue_search_console_sync
POST /api/v1/sites/{site}/search-console/sync
Queue a refresh of the stored Search Console figures of one of your sites (never a live Google call from this request). Refused while the figures are under 15 minutes old. What happens depends on the mode the owner set for this module in Settings → AI activity: Manual stores a proposal the owner applies in the Workspace (the answer has a confirm_url), Hybrid and Autonomous run it. Poll agent_change_status with the id.
Scope: run:audits
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites (see list_sites). |
{
"data": {
"id": 41,
"action": "seo.queue_search_console_sync",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Refresh the Search Console figures of example.com",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Search Console refresh of example.com queued."
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"module": "seo",
"mode": "hybrid",
"hint": null
}
}
queue_ga4_sync
POST /api/v1/sites/{site}/ga4/sync
Queue a refresh of the stored Google Analytics (GA4) figures of one of your sites. Refused while the figures are under 15 minutes old. What happens depends on the mode the owner set for this module in Settings → AI activity: Manual stores a proposal the owner applies in the Workspace (the answer has a confirm_url), Hybrid and Autonomous run it. Poll agent_change_status with the id.
Scope: run:audits
| Parameter | In | Type | Description |
site * | path | string | Host of one of your sites (see list_sites). |
{
"data": {
"id": 41,
"action": "seo.queue_ga4_sync",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Refresh the Google Analytics figures of example.com",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Google Analytics refresh of example.com queued."
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"module": "seo",
"mode": "hybrid",
"hint": null
}
}
list_autopilot_pieces
GET /api/v1/autopilot/pieces
The pieces Content Autopilot wrote that wait for your review, with a preview of the text, open [Confirm: …] markers (a piece with open markers cannot be approved) and the automatic quality checks.
Scope: read:autopilot
| Parameter | In | Type | Description |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"id": 7,
"kind": "partner",
"status": "awaiting_you",
"title": "Five habits of calm launches",
"excerpt": "What small teams do differently.",
"body_markdown": "## Start small …",
"open_confirmations": false,
"quality": {
"passed": true,
"issues": []
},
"created_at": "2026-10-19T07:00:00+00:00"
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"mode": "manual"
}
}
approve_autopilot_piece
POST /api/v1/autopilot/pieces/{id}/approve
Approve a piece waiting for your review: it goes live on Woodander Media with its "Partner content" label, rel="sponsored" and noindex, and is spot-checked by editors. Making it public, so in Manual and Hybrid it waits for the owner (confirm_url); only Autonomous runs it. Poll agent_change_status with the id.
Scope: write:autopilot
| Parameter | In | Type | Description |
id * | path | integer | Id of a piece waiting for your review (see list_autopilot_pieces). |
{
"data": {
"id": 41,
"action": "autopilot.approve_piece",
"risk": "publish",
"status": "pending",
"needs": "confirm",
"summary": "Publish the piece “Five habits of calm launches”",
"diff": [
{
"op": "~",
"kind": "Piece",
"name": "Five habits of calm launches",
"details": [
"Goes live on Woodander Media with its “Partner content” label."
],
"before": "Waiting for your review",
"after": "Published"
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/settings/ai-activity?module=media&change=41",
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "proposed",
"module": "autopilot",
"mode": "manual",
"hint": null
}
}
reject_autopilot_piece
POST /api/v1/autopilot/pieces/{id}/reject
Discard a piece waiting for your review; its allowance comes back and Autopilot schedules another. Removing something always waits for the owner (confirm_url) in every mode. Poll agent_change_status with the id.
Scope: write:autopilot
| Parameter | In | Type | Description |
id * | path | integer | Id of a piece waiting for your review (see list_autopilot_pieces). |
{
"data": {
"id": 41,
"action": "autopilot.reject_piece",
"risk": "destructive",
"status": "pending",
"needs": "confirm",
"summary": "Discard the piece “Five habits of calm launches”",
"diff": [
{
"op": "-",
"kind": "Piece",
"name": "Five habits of calm launches",
"details": [
"Its allowance comes back and a new piece is scheduled."
],
"before": null,
"after": null
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/settings/ai-activity?module=media&change=41",
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "proposed",
"module": "autopilot",
"mode": "manual",
"hint": null
}
}
list_market_listings
GET /api/v1/market/listings
Your own Market listings (offers, swaps and requests) with their status (draft, pending, active, paused), availability and whether edits wait for review.
Scope: read:market
| Parameter | In | Type | Description |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"listing": "sponsored-article-on-lumen-blog",
"type": "offer",
"title": "Sponsored article on Lumen blog",
"format": "sponsored_article",
"status": "active",
"availability": "available",
"summary": "A labelled article with one link.",
"compensation": "money",
"pricing_model": "fixed",
"price": 220,
"currency": "USD",
"has_changes_waiting_for_review": false,
"updated_at": "2026-10-19T08:00:00+00:00"
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"mode": "manual"
}
}
create_market_listing
POST /api/v1/market/listings
Add a private draft listing (an offer, a swap or a request). It is never sent to Woodander editors or made public by an agent: you send it from the editor. Prices are never invented: send the amount you mean. What happens depends on the mode the owner set for this module in Settings → AI activity: Manual stores a proposal the owner applies in the Workspace (the answer has a confirm_url), Hybrid and Autonomous run it. Poll agent_change_status with the id.
Scope: write:market
| Parameter | In | Type | Description |
type * | body | string | What the listing is. One of: offer, swap, request. |
title * | body | string | Title, up to 140 characters. |
format * | body | string | What is sold or needed, e.g. sponsored_article, review, newsletter_sponsorship, social_post, other. |
summary | body | string | One or two sentences, up to 600 characters. |
description | body | string | Longer text, up to 2,500 characters. |
compensation | body | string | How it is settled. One of: money, swap, either. |
swap_give | body | string | What you give in exchange (required for a swap). |
pricing_model | body | string | How it is priced. One of: fixed, from, negotiable, on_request. |
price | body | number | Amount in whole units (required for fixed and from prices), never invented. |
currency | body | string | USD, EUR, GBP or UAH. |
audience_language | body | string | Audience language code, e.g. en. |
audience_country | body | string | Audience country or region key, e.g. us or global. |
topics | body | array | Up to 5 topic keys. |
site_url | body | string | The address of the site or channel, https. |
details | body | object | Optional package, duration, placement, materials, restrictions (short texts). |
timing_note | body | string | Your own estimate of timing, up to 160 characters. |
{
"data": {
"id": 41,
"action": "market.create_listing",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Add the draft listing “Sponsored article on Lumen blog”",
"diff": [
{
"op": "+",
"kind": "Title",
"name": "",
"details": [],
"before": null,
"after": "Sponsored article on Lumen blog"
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Draft listing saved. It is private until you send it to editors from the editor.",
"listing": "sponsored-article-on-lumen-blog"
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"module": "market",
"mode": "hybrid",
"hint": null
}
}
update_market_listing
PATCH /api/v1/market/listings/{listing}
Edit one of your listings (a draft, or a published one: its live version stays until editors review the edit, which you send from the editor). Fields you send replace those fields. A listing with the editors cannot be changed meanwhile. What happens depends on the mode the owner set for this module in Settings → AI activity: Manual stores a proposal the owner applies in the Workspace (the answer has a confirm_url), Hybrid and Autonomous run it. Poll agent_change_status with the id.
Scope: write:market
| Parameter | In | Type | Description |
listing * | path | string | Slug of one of your listings (see list_market_listings). |
title | body | string | Title, up to 140 characters. |
format | body | string | What is sold or needed, e.g. sponsored_article, review, newsletter_sponsorship, social_post, other. |
summary | body | string | One or two sentences, up to 600 characters. |
description | body | string | Longer text, up to 2,500 characters. |
compensation | body | string | How it is settled. One of: money, swap, either. |
swap_give | body | string | What you give in exchange (required for a swap). |
pricing_model | body | string | How it is priced. One of: fixed, from, negotiable, on_request. |
price | body | number | Amount in whole units (required for fixed and from prices), never invented. |
currency | body | string | USD, EUR, GBP or UAH. |
audience_language | body | string | Audience language code, e.g. en. |
audience_country | body | string | Audience country or region key, e.g. us or global. |
topics | body | array | Up to 5 topic keys. |
site_url | body | string | The address of the site or channel, https. |
details | body | object | Optional package, duration, placement, materials, restrictions (short texts). |
timing_note | body | string | Your own estimate of timing, up to 160 characters. |
{
"data": {
"id": 41,
"action": "market.update_listing",
"risk": "reversible",
"status": "pending",
"needs": "apply",
"summary": "Update the listing “Sponsored article on Lumen blog” (price)",
"diff": [
{
"op": "~",
"kind": "Price",
"name": "",
"details": [],
"before": "220",
"after": "240"
}
],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/settings/ai-activity?module=media&change=41",
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "proposed",
"module": "market",
"mode": "manual",
"hint": null
}
}
pause_market_listing
POST /api/v1/market/listings/{listing}/pause
Pause one of your published listings: it disappears from Market until you resume it in the editor. Only hides; nothing is deleted. What happens depends on the mode the owner set for this module in Settings → AI activity: Manual stores a proposal the owner applies in the Workspace (the answer has a confirm_url), Hybrid and Autonomous run it. Poll agent_change_status with the id.
Scope: write:market
| Parameter | In | Type | Description |
listing * | path | string | Slug of one of your listings (see list_market_listings). |
{
"data": {
"id": 41,
"action": "market.pause_listing",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Pause the listing “Sponsored article on Lumen blog”",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Listing paused.",
"listing": "sponsored-article-on-lumen-blog"
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"module": "market",
"mode": "hybrid",
"hint": null
}
}
agent_changes
GET /api/v1/agent/changes
The last 50 change requests of one module (media, seo, autopilot or market): proposals and confirmations waiting for the owner, and what ran. Needs the module's read scope (read:media, read:seo, read:autopilot, read:market).
Scope: any
| Parameter | In | Type | Description |
module * | query | string | The module. One of: media, seo, autopilot, market. |
{
"data": [
{
"id": 41,
"action": "media.update_draft",
"risk": "reversible",
"status": "pending",
"needs": "apply",
"summary": "Update the draft “Notes on a quiet launch” (title)",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": null,
"confirm_url": "https://woodander.com/workspace/settings/ai-activity?module=media&change=41",
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
}
],
"meta": {
"total": 1,
"module": "media",
"mode": "manual"
}
}
agent_change_status
GET /api/v1/agent/changes/{id}
One change request of Media, SEO, Content Autopilot or Market by id: its status (pending, done, failed, rejected), what it waits for and the result. Needs the module's read scope.
Scope: any
| Parameter | In | Type | Description |
id * | path | integer | The id a change request returned. |
{
"data": {
"id": 41,
"action": "seo.run_audit",
"risk": "reversible",
"status": "done",
"needs": null,
"summary": "Start a site audit of example.com",
"diff": [],
"requested_by": "Token “Claude Code”",
"note": null,
"error": null,
"result": {
"summary": "Audit of example.com started. It runs in the background.",
"audit_id": 18
},
"confirm_url": null,
"created_at": "2026-10-19T09:00:00+00:00",
"decided_at": null
},
"meta": {
"outcome": "done",
"module": "seo",
"mode": "hybrid",
"hint": null
}
}
list_site_posts
GET /api/v1/site-posts
The posts you approved for your sites connected through the Woodander API, newest first, with title, slug, meta description, HTML and Markdown. Poll it from your site (for example every hour, with since set to the last run) and publish what is new. Only for a site you connected as "Woodander API (your site pulls the articles)" in Media → Articles for your site: an approved post waits here and nothing is sent to your site.
Scope: read:content
| Parameter | In | Type | Description |
site | query | string | Only posts of this connected site (its host, e.g. yourblog.com). |
since | query | string | Only posts created or changed since this ISO 8601 date (updated_at), e.g. 2026-10-01T00:00:00Z. |
limit | query | integer | Page size, 1 to 100. |
cursor | query | string | meta.next_cursor of the previous page. |
{
"data": [
{
"id": 14,
"site": "yourblog.com",
"title": "How to choose a standing desk",
"slug": "how-to-choose-a-standing-desk",
"meta_description": "Height range, stability and price: what matters.",
"keyword": "standing desk",
"html": "<h2>Start with your height</h2><p>…</p>",
"markdown": "## Start with your height\n\n…",
"status": "published",
"published_at": "2026-10-24T09:00:00+00:00",
"updated_at": "2026-10-24T09:00:00+00:00",
"remote_id": null,
"remote_url": null
}
],
"meta": {
"next_cursor": null,
"limit": 25,
"total": 1,
"site": null,
"since": null
}
}
get_site_post
GET /api/v1/site-posts/{id}
One approved post of a site connected through the Woodander API, with its HTML and Markdown.
Scope: read:content
| Parameter | In | Type | Description |
id * | path | integer | Id of an approved post (see list_site_posts). |
{
"data": {
"id": 14,
"site": "yourblog.com",
"title": "How to choose a standing desk",
"slug": "how-to-choose-a-standing-desk",
"meta_description": "Height range, stability and price: what matters.",
"keyword": "standing desk",
"html": "<h2>Start with your height</h2><p>…</p>",
"markdown": "## Start with your height\n\n…",
"status": "published",
"published_at": "2026-10-24T09:00:00+00:00",
"updated_at": "2026-10-24T09:00:00+00:00",
"remote_id": null,
"remote_url": null
}
}
mark_site_post_live
POST /api/v1/site-posts/{id}/live
Tell Woodander where your site published a post: its address (shown as "View on your site" in the Workspace) and the id your site gave it. Send at least one. Returns the post.
Scope: write:content
| Parameter | In | Type | Description |
id * | path | integer | Id of an approved post (see list_site_posts). |
url | body | string | The public address of the published post (http or https). |
remote_id | body | string | The id your site gave the post, up to 255 characters. |
{
"data": {
"id": 14,
"site": "yourblog.com",
"title": "How to choose a standing desk",
"slug": "how-to-choose-a-standing-desk",
"meta_description": "Height range, stability and price: what matters.",
"keyword": "standing desk",
"html": "<h2>Start with your height</h2><p>…</p>",
"markdown": "## Start with your height\n\n…",
"status": "published",
"published_at": "2026-10-24T09:00:00+00:00",
"updated_at": "2026-10-24T09:00:00+00:00",
"remote_id": "812",
"remote_url": "https://yourblog.com/how-to-choose-a-standing-desk"
}
}