Woodander
NEW IN MEDIA · GUIDE What Is Woodander? A Growth Platform for Projects That Want to Be Found
Woodander Discover Explore projects and businesses Add your project → Woodander TOP Competitive project rankings Claim a spot → Woodander Media News, stories and analysis Write a story → Woodander Market Advertising and partnerships List your channel → Woodander SEO Tools and content growth Check my website → Woodander Outreach Find relevant opportunities Build my route →

Woodander API and MCP

Read your SEO audits, Search Console data, project analytics and articles from your own tools, or let Claude Code, Claude Desktop and Cursor do it for you, and set up Google Tag Manager under rules you choose. Version 1.

Ask Claude about your SEO data

Included in Pro

Connect Woodander to Claude, Cursor or any MCP client, then ask in plain words: “what dropped this week?”, “which pages lost clicks?”, “what should I fix first?”. The answers come from your own audits and Search Console data.

Included in Pro and Scale (see plans). Read-only today. Below is the technical reference for developers.

New to MCP? Read the step-by-step Docs for Claude Code, Claude Desktop and Cursor, or open the new API reference with request and response examples for every endpoint.

Get started

  1. API and MCP access is included in the Pro and Scale plans. See plans.
  2. In the Workspace open Settings, API & MCP, choose what the token may read and an expiry, and create it. The token is shown once.
  3. Send it as a Bearer token. Everything is JSON over HTTPS at https://woodander.com/api/v1.
curl -H "Authorization: Bearer wdr_..." https://woodander.com/api/v1/whoami

A machine-readable description is at https://woodander.com/api/v1/openapi.json (OpenAPI 3.1).

Authentication

Personal access tokens (wdr_...) are sent as Authorization: Bearer <token>. A token belongs to one person and sees only that person's data. It is stored hashed, so we cannot show it again; if you lose it, revoke it and create another. Each token has a name, scopes, an optional expiry and a last used time, and you can revoke it at any time in Settings.

A missing, malformed, expired or revoked token gets 401. A token without the scope an endpoint needs gets 403 with insufficient_scope. If the plan no longer includes API access the answer is 403 with plan_required; the tokens work again when the plan does.

Scopes

ScopeGrants
read:productsYour Discover projects and their details.
write:productsAsk for changes to your Discover projects: page drafts, screenshots, Briefs, review replies, lead status. What happens depends on the mode you set per project: a proposal you apply (Manual), a draft edit (Hybrid), or a public post (Autonomous). Your page itself still changes only after Woodander editors approve the draft.
read:analyticsViews, clicks, referrers and leads of your projects, and the synced Google Analytics (GA4) figures of your sites.
read:seoYour sites, synced Search Console data and site audits.
read:mediaYour Woodander Media articles, posts for your site and Content Studio drafts.
read:gtmYour Google Tag Manager containers, tags, triggers and variables, tracking analysis and site scans.
write:gtmAsk for Tag Manager changes. What happens depends on the mode you set per site: a proposal you apply (Manual) or a change in the separate Woodander workspace (Hybrid). Publishing still waits for your confirmation.
publish:gtmMake Tag Manager versions live without asking you, only where you turned on Autonomous. Give it only to an agent you trust.
read:keywordsKeyword ideas with search volume, competition and CPC (each new lookup uses one of your plan’s daily keyword lookups), your tracked keywords and their ranking history.
write:mediaCreate and edit your private Media drafts. Woodander editors still review every piece; sending a draft to them stays yours. What happens depends on the mode you set for Media in Settings → AI activity.
run:auditsStart site audits and refresh the Search Console and Google Analytics data of your sites, within your plan’s limits. What happens depends on the mode you set for SEO.
read:autopilotThe pieces Content Autopilot wrote that wait for your review.
write:autopilotApprove (publish) or discard pieces waiting for your review. What happens depends on the mode you set for Content Autopilot; discarding always waits for you.
read:marketYour Market listings and their status.
write:marketAdd draft listings, edit and pause your Market listings; sending them to editors stays yours. What happens depends on the mode you set for Market.
read:contentThe approved articles for your own site, for a site connected through the Woodander API to pull.
write:contentTell Woodander where your site published an approved article (its address and id).

whoami works with any scope. read:keywords reads only, but a new keyword_ideas search spends one of the plan's daily keyword lookups. The only write scopes are the Tag Manager ones: write:gtm asks for changes, publish:gtm lets a token publish on its own where the owner turned on Autonomous (see below).

Rate limits

60 requests per minute per token, on REST and MCP together, and 20 Tag Manager changes per minute per token. Over the limit you get 429 with error.code = rate_limited and a Retry-After header (seconds). Requests with unknown tokens are limited per address. Search Console data is served from what we already synced, never fetched from Google per request, so repeated calls are cheap and consistent: the response says when it was synced (meta.synced_at).

Errors

Every error has the same shape. Match on error.code; the message is for people.

{"error": {"code": "not_found", "message": "Site was not found."}}
HTTPCodeMeaning
401unauthenticatedMissing, invalid, expired or revoked token.
403insufficient_scopeThe token does not have the scope this endpoint needs.
403plan_requiredThe owner's plan does not include API access.
404not_foundNot found, or not yours. Other people's data is indistinguishable from missing data.
404search_console_not_connectedThe site has no connected Search Console property.
404gtm_container_not_chosenNo Tag Manager container is chosen for the site.
409busyAnother request of the same account is using its allowance: try again in a moment.
409google_reconnectGoogle no longer accepts the owner's Tag Manager access: they reconnect in the Workspace.
409duplicateThe change would duplicate a tag, trigger or variable the container already has (or the version is already live).
422invalid_requestA parameter is not valid (range, type, cursor).
422validation_failedThe body of a change is not valid; details.errors names the fields.
429rate_limitedToo many requests. See Retry-After.
429limit_reachedThe plan's daily keyword lookups are used up; the message says when they reset (see whoami limits).
429account_spend_capThe account reached this month's keyword data limit.
502provider_errorThe keyword data provider failed; nothing was spent. Try again later.
503provider_not_connectedKeyword data is not connected on this server yet.
503provider_budget_reachedKeyword data is paused until the next month.

Google Tag Manager

The owner connects Google Tag Manager in the Workspace (SEO, Tag Manager), chooses the container for each site and a mode. Reads come from a cached snapshot of the container; Google's quota is shared by every Woodander user (10,000 requests a day, 0.25 a second per Google Cloud project), so a read or a change may answer "status": "queued" with an id: poll gtm_change_status.

  • Manual (default): a change you ask for is stored as a proposal with a human-readable diff; the owner applies it in the Workspace.
  • Hybrid: tags, triggers and variables are changed in a separate GTM workspace named Woodander (never the live Default Workspace) and versions are created. Publishing and deleting return "status": "pending" with a confirm_url: the owner confirms in the Workspace. A token can never confirm its own request.
  • Autonomous: also publishes, only with a token that holds publish:gtm, within a daily publish limit, with the previous version saved for rollback and an email to the owner. Deletes still wait for the owner.

A new tag, trigger or variable that duplicates one in the container (same name, same Google tag id, the same GA4 event to the same id) is refused with duplicate. gtm_plan_migration turns the tags hard-coded on a page into ready inputs for gtm_create_tag and never publishes: remove the snippets from the site's code and publish together with that deploy, or every visit is counted twice. Send sign-ups and purchases from a confirmed step (a success page or a server event), never a button click.

Keywords and rank tracking

With the read:keywords scope: keyword_ideas returns ideas with monthly search volume, competition, CPC and a 12-month trend for a seed keyword or for one of your sites. A new search uses one of the plan's daily keyword lookups (the same search again the same UTC day is free; results are cached for 7 days). tracked_keywords and rank_history read the positions Woodander checks daily or weekly (by plan) for the keywords the owner tracks in the Workspace (SEO, Rank tracking); they never spend anything. whoami returns limits: what is left today, when it resets, and how many keywords are tracked of how many.

REST endpoints

All are GET, under https://woodander.com/api/v1. Each has an MCP tool with the same name and arguments (see below).

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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
rangequerystringPeriod ending yesterday. Default 28d. 3m and longer are live from Google. One of: 7d, 28d, 90d, 3m, 6m, 12m, 16m.
fromquerystringWith to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour).
toquerystringEnd of the custom date range (YYYY-MM-DD), not in the future.
comparequerystringCompare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year.
search_typequerystringSearch Console search type (live). Default web. One of: web, image, video, news, discover, googleNews.
filtersqueryarrayUp 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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
rangequerystringPeriod ending yesterday. Default 28d. 3m and longer are live from Google. One of: 7d, 28d, 3m, 6m, 12m, 16m.
fromquerystringWith to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour).
toquerystringEnd of the custom date range (YYYY-MM-DD), not in the future.
comparequerystringCompare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year.
search_typequerystringSearch Console search type (live). Default web. One of: web, image, video, news, discover, googleNews.
dimensionquerystringGroup the rows by this dimension instead of the default (live). One of: query, page, country, device, date, appearance.
filtersqueryarrayUp 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.
filterquerystringKeep queries containing this text.
sortquerystringDefault clicks. Position sorts best first. One of: clicks, impressions, ctr, position.
limitqueryintegerPage size, 1 to 200.
cursorquerystringmeta.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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
rangequerystringPeriod ending yesterday. Default 28d. 3m and longer are live from Google. One of: 7d, 28d, 3m, 6m, 12m, 16m.
fromquerystringWith to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour).
toquerystringEnd of the custom date range (YYYY-MM-DD), not in the future.
comparequerystringCompare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year.
search_typequerystringSearch Console search type (live). Default web. One of: web, image, video, news, discover, googleNews.
dimensionquerystringGroup the rows by this dimension instead of the default (live). One of: query, page, country, device, date, appearance.
filtersqueryarrayUp 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.
filterquerystringKeep page URLs containing this text.
sortquerystringDefault clicks. Position sorts best first. One of: clicks, impressions, ctr, position.
limitqueryintegerPage size, 1 to 200.
cursorquerystringmeta.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

ParameterInTypeDescription
site *pathstringHost 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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
rangequerystringPeriod ending yesterday. Default 28d. 3m and longer are live from Google. One of: 28d, 90d, 3m, 6m, 12m, 16m.
fromquerystringWith to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour).
toquerystringEnd of the custom date range (YYYY-MM-DD), not in the future.
comparequerystringCompare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year.
metricsqueryarrayGA4 metrics to return (live): sessions, totalUsers, newUsers, engagedSessions, engagementRate, bounceRate, screenPageViews, eventCount, keyEvents, sessionKeyEventRate, averageSessionDuration, userEngagementDuration. Rates are percentages.
filtersqueryarrayUp 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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
rangequerystringPeriod ending yesterday. Default 28d. 3m and longer are live from Google. One of: 28d, 90d, 3m, 6m, 12m, 16m.
fromquerystringWith to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour).
toquerystringEnd of the custom date range (YYYY-MM-DD), not in the future.
comparequerystringCompare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year.
dimensionquerystringGroup the rows by this GA4 dimension instead of the default (live), e.g. country, deviceCategory, sessionDefaultChannelGroup, eventName, landingPage, date.
metricsqueryarrayGA4 metrics to return (live): sessions, totalUsers, newUsers, engagedSessions, engagementRate, bounceRate, screenPageViews, eventCount, keyEvents, sessionKeyEventRate, averageSessionDuration, userEngagementDuration. Rates are percentages.
filtersqueryarrayUp 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.
filterquerystringKeep page paths containing this text.
sortquerystringDefault sessions. One of: sessions, users, engaged_sessions, key_events.
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
rangequerystringPeriod ending yesterday. Default 28d. 3m and longer are live from Google. One of: 28d, 90d, 3m, 6m, 12m, 16m.
fromquerystringWith to: a custom date range (YYYY-MM-DD), up to 16 months back. Answered live from Google (cached for an hour).
toquerystringEnd of the custom date range (YYYY-MM-DD), not in the future.
comparequerystringCompare with the previous period of the same length, or the same dates a year earlier (live). One of: none, previous, year.
dimensionquerystringGroup the rows by this GA4 dimension instead of the default (live), e.g. country, deviceCategory, sessionDefaultChannelGroup, eventName, landingPage, date.
metricsqueryarrayGA4 metrics to return (live): sessions, totalUsers, newUsers, engagedSessions, engagementRate, bounceRate, screenPageViews, eventCount, keyEvents, sessionKeyEventRate, averageSessionDuration, userEngagementDuration. Rates are percentages.
filtersqueryarrayUp 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.
filterquerystringKeep sources containing this text.
sortquerystringDefault sessions. One of: sessions, users, engaged_sessions, key_events.
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
id *pathintegerAudit 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

ParameterInTypeDescription
url *querystringFull page address on one of your sites (see list_sites), e.g. https://example.com/pricing.
strategyquerystringDevice: 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

ParameterInTypeDescription
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
slug *pathstringSlug 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

ParameterInTypeDescription
slug *pathstringSlug of one of your projects (see list_products).
rangequerystringPeriod. 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

ParameterInTypeDescription
statusquerystringKeep one status, e.g. draft, in_review, published.
typequerystringKeep one kind. One of: media, site_post, seo_content.
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
id *pathstringArticle 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

ParameterInTypeDescription
site *pathstringHost 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
refreshquerybooleanRead 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
eventsquerystringYour 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
urlquerystringA 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
urlquerystringA 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

ParameterInTypeDescription
site *pathstringHost 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

ParameterInTypeDescription
id *pathintegerThe 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
name *bodystringTag name, e.g. "GA4 event - sign_up".
type *bodystringgoogtag = 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_idbodystringgoogtag: the id it loads, e.g. G-ABC123 or AW-123456.
measurement_idbodystringgaawe: the GA4 measurement id the event is sent to, e.g. G-ABC123.
event_namebodystringgaawe: 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_parametersbodyarraygaawe: event parameters, a list of {"name", "value"} (value may be a {{Variable}}).
htmlbodystringhtml: the Custom HTML.
conversion_idbodystringawct: Google Ads conversion id.
conversion_labelbodystringawct: Google Ads conversion label.
firing_trigger_idsbodyarrayIds of the triggers that fire it (2147479553 = All Pages). One of firing_trigger_ids or firing_trigger_names is required to create.
firing_trigger_namesbodyarrayTrigger names instead of ids, resolved when the change runs (use it for a trigger another change creates first).
pausedbodybooleanPause the tag.
notesbodystringNotes 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
id *pathstringGTM tag id (numeric, see gtm_get_container).
namebodystringTag name, e.g. "GA4 event - sign_up".
google_tag_idbodystringgoogtag: the id it loads, e.g. G-ABC123 or AW-123456.
measurement_idbodystringgaawe: the GA4 measurement id the event is sent to, e.g. G-ABC123.
event_namebodystringgaawe: 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_parametersbodyarraygaawe: event parameters, a list of {"name", "value"} (value may be a {{Variable}}).
htmlbodystringhtml: the Custom HTML.
conversion_idbodystringawct: Google Ads conversion id.
conversion_labelbodystringawct: Google Ads conversion label.
firing_trigger_idsbodyarrayIds of the triggers that fire it (2147479553 = All Pages). One of firing_trigger_ids or firing_trigger_names is required to create.
firing_trigger_namesbodyarrayTrigger names instead of ids, resolved when the change runs (use it for a trigger another change creates first).
pausedbodybooleanPause the tag.
notesbodystringNotes 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
id *pathstringGTM 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
name *bodystringTrigger name, e.g. "CE - sign_up".
type *bodystringTrigger type. One of: pageview, domReady, windowLoaded, customEvent, click, linkClick, formSubmission, historyChange.
event_namebodystringcustomEvent: the dataLayer event it listens for.
filtersbodyarrayConditions, a list of {"variable": "Page Path", "operator": "equals|contains|startsWith|endsWith|matchRegex", "value"}.
notesbodystringNotes 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
id *pathstringGTM trigger id (numeric, see gtm_get_container).
namebodystringTrigger name, e.g. "CE - sign_up".
event_namebodystringcustomEvent: the dataLayer event it listens for.
filtersbodyarrayConditions, a list of {"variable": "Page Path", "operator": "equals|contains|startsWith|endsWith|matchRegex", "value"}.
notesbodystringNotes 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
name *bodystringVariable name.
type *bodystringv = data layer variable, c = constant, u = URL, k = 1st-party cookie, jsm = custom JavaScript. One of: v, c, u, k, jsm.
data_layer_namebodystringv: the dataLayer key, e.g. ecommerce.value.
valuebodystringc: the constant value.
componentbodystringu: the part of the URL. One of: URL, HOST, PATH, QUERY, FRAGMENT.
cookie_namebodystringk: the cookie name.
javascriptbodystringjsm: function () { return ...; }
notesbodystringNotes 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
id *pathstringGTM variable id (numeric, see gtm_get_container).
namebodystringVariable name.
data_layer_namebodystringv: the dataLayer key, e.g. ecommerce.value.
valuebodystringc: the constant value.
componentbodystringu: the part of the URL. One of: URL, HOST, PATH, QUERY, FRAGMENT.
cookie_namebodystringk: the cookie name.
javascriptbodystringjsm: function () { return ...; }
notesbodystringNotes 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
name *bodystringVersion name.
notesbodystringWhat 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
version_id *pathstringThe 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

ParameterInTypeDescription
site *pathstringHost of one of your sites with a Tag Manager container chosen for it (see gtm_list_containers).
version_idbodystringA 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

ParameterInTypeDescription
site *querystringHost of one of your sites (see list_sites); the keywords belong to it and set the default location and language.
seedquerystringUp to 5 keywords separated by commas. Leave it out for keywords of the site itself.
locationqueryintegerLocation code, e.g. 2840 United States, 2826 United Kingdom, 2276 Germany, 2724 Spain, 2804 Ukraine. Default: the site's market.
languagequerystringLanguage 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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
limitqueryintegerPage size, 1 to 200.
cursorquerystringmeta.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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
id *pathintegerTracked keyword id (see tracked_keywords).
daysqueryintegerPeriod 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

ParameterInTypeDescription
site *pathstringHost of one of your sites, e.g. example.com (see list_sites).
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
slug *pathstringSlug 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

ParameterInTypeDescription
slug *pathstringSlug of one of your projects (see discover_list_products).
statusquerystringOnly leads with this status. One of: new, contacted, archived.
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
slug *pathstringSlug of one of your projects (see discover_list_products).
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
slug *pathstringSlug of one of your projects (see discover_list_products).
rangequerystringPeriod. 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

ParameterInTypeDescription
slug *pathstringSlug 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

ParameterInTypeDescription
id *pathintegerThe 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

ParameterInTypeDescription
slug *pathstringSlug of one of your projects (see discover_list_products).
localebodystringA 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_descriptionbodystringTagline, up to 80 characters.
descriptionbodystringWhat it does, 80 to 600 characters.
highlightsbodyarrayUp to 6 short highlights.
faqbodyarrayUp to 10 entries {"question", "answer"}, both required.
linksbodyarrayUp to 6 extra links {"label", "url"}.
submitbodybooleanAlso 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

ParameterInTypeDescription
slug *pathstringSlug of one of your projects (see discover_list_products).
url *bodystringhttps address of the image (no redirects, public hosts only).
altbodystringAlt 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

ParameterInTypeDescription
slug *pathstringSlug of one of your projects (see discover_list_products).
title *bodystringUp to 120 characters.
body *bodystring40 to 1200 characters, up to 3 paragraphs, no markup.
source_urlbodystringA 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

ParameterInTypeDescription
slug *pathstringSlug of one of your projects (see discover_list_products).
review_id *pathintegerReview id (see discover_reviews).
reply *bodystringUp 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

ParameterInTypeDescription
slug *pathstringSlug of one of your projects (see discover_list_products).
lead_id *pathintegerLead id (see discover_leads).
status *bodystringThe 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

ParameterInTypeDescription
sitequerystringKeep 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

ParameterInTypeDescription
sitequerystringKeep one of your sites (host, see list_sites).
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
title *bodystringTitle, up to 160 characters.
excerptbodystringTeaser, up to 300 characters.
body_markdown *bodystringThe body in Markdown (headings, lists, links, images by https address). Unsafe markup is removed.
locale *bodystringLanguage code of the piece, e.g. en or uk.
typebodystringFormat of the piece (default story).
topic_idbodyintegerA Media topic id.
related_product_idbodyintegerOne 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

ParameterInTypeDescription
id *pathstringId of one of your Media drafts, e.g. media-12 (see list_articles).
titlebodystringTitle, up to 160 characters.
excerptbodystringTeaser, up to 300 characters.
body_markdownbodystringThe 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

ParameterInTypeDescription
site *pathstringHost 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

ParameterInTypeDescription
site *pathstringHost 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

ParameterInTypeDescription
site *pathstringHost 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

ParameterInTypeDescription
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
id *pathintegerId 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

ParameterInTypeDescription
id *pathintegerId 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

ParameterInTypeDescription
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
type *bodystringWhat the listing is. One of: offer, swap, request.
title *bodystringTitle, up to 140 characters.
format *bodystringWhat is sold or needed, e.g. sponsored_article, review, newsletter_sponsorship, social_post, other.
summarybodystringOne or two sentences, up to 600 characters.
descriptionbodystringLonger text, up to 2,500 characters.
compensationbodystringHow it is settled. One of: money, swap, either.
swap_givebodystringWhat you give in exchange (required for a swap).
pricing_modelbodystringHow it is priced. One of: fixed, from, negotiable, on_request.
pricebodynumberAmount in whole units (required for fixed and from prices), never invented.
currencybodystringUSD, EUR, GBP or UAH.
audience_languagebodystringAudience language code, e.g. en.
audience_countrybodystringAudience country or region key, e.g. us or global.
topicsbodyarrayUp to 5 topic keys.
site_urlbodystringThe address of the site or channel, https.
detailsbodyobjectOptional package, duration, placement, materials, restrictions (short texts).
timing_notebodystringYour 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

ParameterInTypeDescription
listing *pathstringSlug of one of your listings (see list_market_listings).
titlebodystringTitle, up to 140 characters.
formatbodystringWhat is sold or needed, e.g. sponsored_article, review, newsletter_sponsorship, social_post, other.
summarybodystringOne or two sentences, up to 600 characters.
descriptionbodystringLonger text, up to 2,500 characters.
compensationbodystringHow it is settled. One of: money, swap, either.
swap_givebodystringWhat you give in exchange (required for a swap).
pricing_modelbodystringHow it is priced. One of: fixed, from, negotiable, on_request.
pricebodynumberAmount in whole units (required for fixed and from prices), never invented.
currencybodystringUSD, EUR, GBP or UAH.
audience_languagebodystringAudience language code, e.g. en.
audience_countrybodystringAudience country or region key, e.g. us or global.
topicsbodyarrayUp to 5 topic keys.
site_urlbodystringThe address of the site or channel, https.
detailsbodyobjectOptional package, duration, placement, materials, restrictions (short texts).
timing_notebodystringYour 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

ParameterInTypeDescription
listing *pathstringSlug 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

ParameterInTypeDescription
module *querystringThe 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

ParameterInTypeDescription
id *pathintegerThe 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

ParameterInTypeDescription
sitequerystringOnly posts of this connected site (its host, e.g. yourblog.com).
sincequerystringOnly posts created or changed since this ISO 8601 date (updated_at), e.g. 2026-10-01T00:00:00Z.
limitqueryintegerPage size, 1 to 100.
cursorquerystringmeta.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

ParameterInTypeDescription
id *pathintegerId 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

ParameterInTypeDescription
id *pathintegerId of an approved post (see list_site_posts).
urlbodystringThe public address of the published post (http or https).
remote_idbodystringThe 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"
    }
}

MCP server

The same data and Tag Manager actions over the Model Context Protocol (Streamable HTTP), at https://woodander.com/mcp. It uses the same token, the same scopes and the same rate limits. A token only sees the tools its scopes allow.

Claude Code

claude mcp add --transport http woodander https://woodander.com/mcp --header "Authorization: Bearer <token>"

Claude Desktop (claude_desktop_config.json, needs Node.js)

{
    "mcpServers": {
        "woodander": {
            "command": "npx",
            "args": [
                "-y",
                "mcp-remote",
                "https://woodander.com/mcp",
                "--header",
                "Authorization:Bearer <token>"
            ]
        }
    }
}

Cursor (.cursor/mcp.json)

{
    "mcpServers": {
        "woodander": {
            "url": "https://woodander.com/mcp",
            "headers": {
                "Authorization": "Bearer <token>"
            }
        }
    }
}

Tools

whoami, list_sites, search_console_summary, search_console_queries, search_console_pages, search_opportunities, ga4_summary, ga4_pages, ga4_sources, list_audits, get_audit, pagespeed_check, list_products, get_product, product_analytics, list_articles, get_article, gtm_list_containers, gtm_list_workspaces, gtm_get_container, gtm_analyze_tracking, gtm_scan_site, gtm_plan_migration, gtm_list_changes, gtm_change_status, gtm_create_tag, gtm_update_tag, gtm_delete_tag, gtm_create_trigger, gtm_update_trigger, gtm_create_variable, gtm_update_variable, gtm_create_version, gtm_publish_version, gtm_rollback, keyword_ideas, tracked_keywords, rank_history, ai_visibility, discover_list_products, discover_product_page, discover_leads, discover_reviews, discover_analytics, discover_list_changes, discover_change_status, discover_update_page, discover_add_screenshot_from_url, discover_post_brief, discover_reply_review, discover_update_lead_status, uptime_status, list_reports, create_article_draft, update_article_draft, run_audit, queue_search_console_sync, queue_ga4_sync, list_autopilot_pieces, approve_autopilot_piece, reject_autopilot_piece, list_market_listings, create_market_listing, update_market_listing, pause_market_listing, agent_changes, agent_change_status, list_site_posts, get_site_post, mark_site_post_live. Arguments are the parameters above; results are the same JSON as the REST endpoint. Errors come back as a tool error whose text starts with the stable code (for example not_found: Site was not found.).

Resources

  • woodander://sites: your sites with Search Console status.
  • woodander://sites/{host}/seo-summary: the last 28 days and the opportunities.
  • woodander://sites/{host}/latest-audit: the latest finished audit.

Prompts

seo_review (optional argument site) tells the agent which tools to call to review a site and what to report.

speed_fix (optional argument site) walks the agent through pagespeed_check on the key pages, mapping every slow resource to files in your repository, proposing code changes and re-checking after deploy.

Woodander keep growing.