API Reference
SearchFlow API reference
The SearchFlow API is versioned under /api/v1. All workflow endpoints accept and return JSON. Long-running operations return 202 Accepted and are tracked by run_id. The OAuth endpoints at the end of this page are the exception to the prefix: the specs that define them put them on the origin.
Base URL: https://api.searchflow.app · API prefix: /api/v1 · OAuth endpoints at the root
Last updated: 9 September 2026
Conventions
- Content-Type: application/json on POST requests.
- Authentication: Authorization: Bearer <api_key> or a Supabase session JWT from the dashboard. Required on every workflow endpoint.
- Billing: workflow runs are debited when the run is created and refunded automatically if the run fails.
- Async workflows: POST returns 202 with a run envelope; poll GET /runs/{run_id} for the result.
- UUIDs: run_id values are UUID v4 strings.
- MCP: the /mcp endpoint takes the same API key, or a token a client obtains for itself through the OAuth endpoints at the root of this page.
- The OAuth endpoints are not workflows and are never billed. Registering a client, signing in, consenting and refreshing a token all cost nothing; only the runs started afterwards cost credits.
- Platform endpoints: everything under /api/v1/health and /api/v1/integrations is not a workflow run — no run_id, no polling, and nothing debited.
Error format
HTTP errors return a JSON body with a detail field. Validation errors may return an array of objects with loc, msg, and type.
{ "detail": "Workflow run not found" }/api/v1/healthNo authHealth check
Returns service status, environment, and the commit the running image was built from. Useful for uptime monitoring and for confirming which revision is live.
Status codes
200Service is healthy.
Response body
Health payload.
{
"status": "ok",
"service": "SearchFlow API",
"environment": "production",
"revision": "9ab17c5f3d2e1b8a4c6d0e7f2a1b3c5d7e9f0a1b"
}Example
curl https://api.searchflow.app/api/v1/health/api/v1/workflows/keyword-clustering/runsAuth requiredCreate keyword clustering run
Validates the input, debits the run cost from your credit balance, creates a workflow run, and enqueues it for background execution. Returns immediately with status queued. Credits are refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed.503Supabase or Redis not configured.500Failed to create workflow run.
Request body
Keyword list, locale, clustering options.
{
"keywords": [
{ "keyword": "string (1-512 chars)", "volume": "integer | null" }
],
"locale": {
"country": "string (default: us)",
"language": "string (default: en)",
"geolocation": "string | null"
},
"serp_similarity_threshold": "float 0.1-1.0 (default: 0.4)",
"thematic_clustering": {
"enabled": "boolean (default: false)",
"label_with_ai": "boolean (default: true)"
},
"domain_tracking": {
"enabled": "boolean (default: false)",
"domain": "string | null (required when enabled)"
}
}Response body
Run envelope. result is null until the workflow completes.
{
"run_id": "uuid",
"workflow": "keyword_clustering",
"status": "queued | running | completed | failed",
"received_keywords": 42,
"progress": 0,
"current_step": "string | null",
"result": "KeywordClusteringResult | null",
"errors": [{ "keyword": "string", "stage": "string", "message": "string" }],
"message": "string | null"
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/keyword-clustering/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"keywords": [{ "keyword": "seo audit", "volume": 1200 }],
"locale": { "country": "us", "language": "en" },
"serp_similarity_threshold": 0.4,
"thematic_clustering": { "enabled": true },
"domain_tracking": { "enabled": false }
}'/api/v1/workflows/keyword-clustering/runs/{run_id}Auth requiredGet keyword clustering run
Returns the current state of a run. Poll this endpoint until status is completed or failed. You can only access your own runs.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed.
{
"run_id": "uuid",
"workflow": "keyword_clustering",
"status": "queued | running | completed | failed",
"received_keywords": 42,
"progress": 85,
"current_step": "semantic_clustering",
"result": {
"workflow": "keyword_clustering",
"locale": { "country": "us", "language": "en" },
"serp_clusters": [
{
"id": "serp_1",
"centroid": "seo audit",
"keywords": [
{ "keyword": "seo audit", "volume": 1200, "keyword_position": null, "keyword_url": null }
]
}
],
"semantic_clusters": [
{
"id": "semantic_1",
"label": "SEO audits",
"serp_cluster_ids": ["serp_1"],
"keywords": ["seo audit"]
}
],
"errors": [],
"meta": {
"keyword_count": 42,
"serp_cluster_count": 8,
"semantic_cluster_count": 3,
"serp_similarity_threshold": 0.4,
"domain_tracking_enabled": false,
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Building thematic clusters"
}Example
curl https://api.searchflow.app/api/v1/workflows/keyword-clustering/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/indexation-check/runsAuth requiredCreate indexation check run
Validates and de-duplicates the URL list, debits the run cost, creates a workflow run, and enqueues it on the short-task workers. Returns immediately with status queued. Credits are refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (invalid URL).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
URL list, locale, categorization options.
{
"urls": ["string (1-5000 entries)"],
"locale": {
"country": "string (default: us)",
"language": "string (default: en)",
"geolocation": "string | null"
},
"categorization": {
"enabled": "boolean (default: false)",
"label_with_ai": "boolean (default: true)"
}
}Response body
Run envelope. received_urls counts the URLs left after duplicates are collapsed — that is what is billed.
{
"run_id": "uuid",
"workflow": "indexation_check",
"status": "queued | running | completed | failed",
"received_urls": 20,
"progress": 0,
"current_step": "string | null",
"result": "IndexationCheckResult | null",
"errors": [{ "item": "string", "stage": "string", "message": "string" }],
"message": "string | null"
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/indexation-check/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://example.com/", "https://example.com/pricing"],
"locale": { "country": "fr", "language": "fr" },
"categorization": { "enabled": true }
}'/api/v1/workflows/indexation-check/runs/{run_id}Auth requiredGet indexation check run
Returns the current state of a run. Poll this endpoint until status is completed or failed. You can only access your own runs.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed.
{
"run_id": "uuid",
"workflow": "indexation_check",
"status": "queued | running | completed | failed",
"received_urls": 20,
"progress": 88,
"current_step": "categorization",
"result": {
"workflow": "indexation_check",
"locale": { "country": "fr", "language": "fr" },
"results": [
{
"url": "https://example.com/pricing",
"status": "indexed | not_indexed | error",
"matched_url": "https://example.com/pricing",
"title": "Pricing",
"description": "Plans and pricing.",
"site_results_count": 1,
"category_id": "category_1",
"category": "Landing pages",
"theme": "Pricing and plans",
"error": null
}
],
"categories": [
{
"id": "category_1",
"category": "Landing pages",
"theme": "Pricing and plans",
"url_count": 4,
"urls": ["https://example.com/pricing"]
}
],
"errors": [],
"meta": {
"url_count": 20,
"indexed_count": 17,
"not_indexed_count": 3,
"error_count": 0,
"indexation_rate": 0.85,
"categorization_enabled": true,
"category_count": 4,
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Grouping URLs into categories"
}Example
curl https://api.searchflow.app/api/v1/workflows/indexation-check/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/ecommerce-category-content/runsAuth requiredCreate category content run
Generates a short intro and a long-form text for each e-commerce category query, researched against the live SERP. Up to 100 queries per run, processed in parallel. Costs 2 credits per content, debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (empty or over 100 queries).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
Queries, locale, output format, length targets, and the optional brand context. Queries are lowercased and deduplicated.
{
"queries": [
{
"query": "string (1-256 chars)",
"category_name": "string | null (defaults to the query)",
"category_url": "string | null"
}
],
"locale": {
"country": "string (default: us)",
"language": "string (default: en)",
"geolocation": "string | null"
},
"output_format": "html | text | both (default: html)",
"short_text": { "min_words": "integer (default: 50)", "max_words": "integer (default: 100)" },
"long_text": { "min_words": "integer (default: 300)", "max_words": "integer (default: 400)" },
"research": {
"competitors": "integer 1-10 (default: 4)",
"max_chars_per_page": "integer 500-10000 (default: 4000)",
"include_paa": "boolean (default: true)",
"include_related_searches": "boolean (default: true)"
},
"brand": {
"name": "string | null",
"site_url": "string | null",
"site_description": "string | null",
"tone_of_voice": "string | null",
"audience": "string | null",
"usp": ["string"],
"constraints": ["string"],
"banned_words": ["string"],
"must_include_keywords": ["string"],
"call_to_action": "string | null"
},
"custom_instructions": "string | null (max 4000 chars)"
}Response body
Run envelope. result is null until the workflow completes.
{
"run_id": "uuid",
"workflow": "ecommerce_category_content",
"status": "queued | running | completed | failed",
"received_queries": 2,
"progress": 0,
"current_step": "string | null",
"result": "EcommerceCategoryContentResult | null",
"errors": [{ "item": "string", "stage": "string", "message": "string" }],
"message": "string | null"
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/ecommerce-category-content/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"queries": [{ "query": "chaussures de randonnée femme" }],
"locale": { "country": "fr", "language": "fr" },
"output_format": "html"
}'/api/v1/workflows/ecommerce-category-content/runs/{run_id}Auth requiredGet category content run
Returns the current state of a run. Poll until status is completed or failed. A query that fails to generate comes back as an item with status failed rather than failing the whole run.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed.
{
"run_id": "uuid",
"workflow": "ecommerce_category_content",
"status": "completed",
"received_queries": 1,
"progress": 100,
"current_step": "completed",
"result": {
"workflow": "ecommerce_category_content",
"locale": { "country": "fr", "language": "fr" },
"items": [
{
"query": "chaussures de randonnée femme",
"category_name": "chaussures de randonnée femme",
"status": "completed",
"short_text": { "html": "<p>…</p>", "word_count": 68, "char_count": 446 },
"long_text": {
"html": "<h2>…</h2><p>…</p>",
"word_count": 362,
"char_count": 2380,
"headings": ["…"]
},
"research": {
"competitors": [
{ "position": 1, "url": "https://…", "domain": "…", "title": "…", "extracted": true }
],
"people_also_ask": ["…"],
"related_searches": ["…"]
}
}
],
"errors": [],
"meta": {
"query_count": 1,
"completed_count": 1,
"failed_count": 0,
"output_format": "html",
"model": "gpt-5.6-luna",
"competitors_per_query": 4,
"max_chars_per_page": 4000,
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Category content completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/ecommerce-category-content/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/product-content/runsAuth requiredCreate product content run
Writes the fields of a product sheet — title tag, meta description, short and long descriptions, Google Shopping description — from the live SERP for the keyword buyers use to find the product. Fields can be requested individually; omit the fields block to get all five. Costs 1 credit per product plus 0.5 per field, debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (no usable keyword, over 200 products, or a half-declared length override).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
Attributes are stated as fact and never invented, so pass whatever specifications you already hold. Passing product_url reads the current page and rewrites it instead of writing from scratch.
{
"products": [
{
"keyword": "string (required, the search buyers use)",
"name": "string (optional, display name)",
"attributes": { "Label": "value" },
"product_url": "string (optional, rewrite an existing page)",
"context": "string (optional, notes for this product)"
}
],
"locale": { "country": "fr", "language": "fr" },
"output_format": "html | text | both",
"fields": {
"title": { "instructions": "string", "min": 45, "max": 60 },
"meta_description": {},
"short_description": {},
"long_description": {},
"shopping_description": { "enabled": false }
},
"brand": { "name": "string", "tone_of_voice": "string", "audience": "string" },
"custom_instructions": "string (applies to every field)",
"research": { "competitors": 4 }
}Response body
202 Accepted. received_products echoes how many products survived deduplication — that is the number you were billed for.
{
"run_id": "uuid",
"workflow": "product_content",
"status": "queued",
"received_products": 1,
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/product-content/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"products": [
{
"keyword": "chaussures de running homme",
"name": "Nike Pegasus 41",
"attributes": { "Drop": "10 mm", "Poids": "285 g" }
}
],
"locale": { "country": "fr", "language": "fr" },
"fields": { "title": {}, "meta_description": {} }
}'/api/v1/workflows/product-content/runs/{run_id}Auth requiredGet product content run
Returns the current state of a run. Poll until status is completed or failed. Every field carries a budget block reporting what it measured and whether it fits — a field that could not be brought inside its budget is surfaced rather than hidden.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed.
{
"run_id": "uuid",
"workflow": "product_content",
"status": "completed",
"received_products": 1,
"progress": 100,
"result": {
"workflow": "product_content",
"locale": { "country": "fr", "language": "fr" },
"items": [
{
"keyword": "chaussures de running homme",
"name": "Nike Pegasus 41",
"status": "completed",
"fields": {
"title": {
"text": "Chaussures de running homme route Pegasus 41 Nike",
"char_count": 49,
"budget": { "min": 45, "max": 60, "unit": "characters", "measured": 49, "within_budget": true }
},
"long_description": {
"html": "<p>…</p><ul><li>…</li></ul>",
"word_count": 212,
"budget": { "min": 180, "max": 280, "unit": "words", "measured": 212, "within_budget": true }
}
},
"research": {
"competitors": [
{ "position": 1, "url": "https://…", "domain": "…", "title": "…", "extracted": true }
],
"people_also_ask": ["…"],
"related_searches": ["…"]
}
}
],
"errors": [],
"meta": {
"product_count": 1,
"completed_count": 1,
"failed_count": 0,
"fields": ["title", "long_description"],
"model": "gpt-5.6-luna",
"competitors_per_product": 4,
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Product content completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/product-content/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/keyword-research/runsAuth requiredCreate keyword research run
Builds a full keyword study for ONE market — one country, one language. Widens from the Google Keyword Planner on your seeds, then from the categories those keywords belong to, then from what the site and each competitor already rank on; filters every source against your business description and your exclusions; cuts to the best max_keywords by volume; reads one live SERP per keyword and clusters on the overlap; scores each cluster against the business twice, by embedding and by model; and returns the study as a workbook of six sheets plus a JSON summary. Costs 80 credits per study, plus 10 per batch of 20 seeds, plus 20 per ranked source, plus 55 per 1,000 of max_keywords — debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (no site and no market, more than one market, a business description under 20 characters, a multilingual ccTLD with no language declared, or a country DataForSEO has no keyword data for).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
Give a site to get current positions and the site's own keywords; omit it entirely to research a market for a site that does not exist yet. A bare ccTLD resolves its own market (example.fr is France in French); a .be, .ch or .com must declare the country and language, because guessing would produce a confident study of a market the site may not serve. Say where the language lives: the whole domain, a folder (path_prefix), or a subdomain — put the subdomain in the domain itself, fr.example.com. Seeds are broad themes the Keyword Planner expands, not long queries: send 'assurance auto', never 'assurance auto pas chere jeune conducteur'. Leave seeds empty and the run writes twenty of its own from business_description; leave competitors empty and it finds the ones ranking for those seeds. brand keeps the client's own branded queries in the study but reports them apart.
{
"business_description": "string (required, 20-4000 chars)",
"site": {
"domain": "example.fr",
"path_prefix": "/fr",
"country": "fr",
"language": "fr"
},
"markets": [{ "country": "fr", "language": "fr" }],
"brand": { "name": "Bultex", "variants": ["bultex avis"] },
"competitors": ["competitor.fr"],
"seeds": ["matelas", "sommier"],
"max_keywords": 2000,
"include_site_keywords": true,
"custom_instructions": "string (optional) - what to leave out",
"expansion": { "discover_competitors": true, "competitor_limit": 2 },
"filtering": { "relevance_threshold": 0.05, "min_search_volume": 0 },
"clustering": { "serp_similarity_threshold": 0.4 }
}Response body
202 Accepted. received_seeds echoes how many seeds the payload carried; the seed batches it implies are part of your credit cost.
{
"run_id": "uuid",
"workflow": "keyword_research",
"status": "queued",
"received_seeds": 2,
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/keyword-research/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"business_description": "Boutique de literie haut de gamme : matelas, sommiers, oreillers.",
"site": { "domain": "literie.fr" },
"brand": { "name": "Literie.fr" },
"competitors": ["grandlitier.com"],
"seeds": ["matelas", "sommier", "oreiller"],
"max_keywords": 1000,
"expansion": { "discover_competitors": false }
}'/api/v1/workflows/keyword-research/runs/{run_id}Auth requiredGet keyword research run
Returns the current state of a run. Poll until status is completed or failed. The full study is the workbook at result.export.url — six sheets, downloadable for one year. The JSON carries the aggregates, every SERP cluster, and the first 50 rows of each sheet, so an agent can act on the study without moving twenty megabytes. mode is observed, not declared: existing_site once the site is found ranking for anything, new_site otherwise.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed. The workbook sheets are Summary, Roadmap (the merged list, clustered), Research (Keyword Planner and category ideas), Competitors, Clusters, Seasonality and Charts. cluster_similarity is the raw cosine to your description — it tops out near 0.5 even on a perfect match, so sort on it and show cluster_similarity_rank, its percentile inside this study. cluster_relevance_score is the model's own 0-100 read of the same question. meta.provider_cost reports what the run actually spent at DataForSEO, per endpoint.
{
"run_id": "uuid",
"workflow": "keyword_research",
"status": "completed",
"received_seeds": 3,
"progress": 100,
"result": {
"workflow": "keyword_research",
"market": {
"key": "literie.fr [fr-fr]",
"country": "fr",
"language": "fr",
"label": "literie.fr (fr-fr)",
"domain": "literie.fr",
"host": "literie.fr"
},
"mode": "existing_site",
"seeds": ["matelas", "sommier", "oreiller"],
"seed_origin": "declared",
"competitors": [
{ "domain": "grandlitier.com", "source": "declared", "keywords_found": 612 }
],
"brand": { "name": "Literie.fr", "variants": [], "keyword_count": 34, "volume": 9100 },
"search_console": { "connected": false, "keyword_count": 0, "detail": "..." },
"taxonomy": { "levels": 3, "pillars": [] },
"clusters": [
{
"id": "serp_1",
"head": "matelas memoire de forme",
"keyword_count": 18,
"volume": 12400,
"difficulty": 17,
"intent": "commercial",
"similarity": 0.4812,
"similarity_rank": 96,
"relevance_score": 95,
"pillar": "Matelas par technologie",
"cluster": "Memoire de forme",
"best_position": 7,
"competitor_count": 2,
"sample_keywords": ["matelas memoire de forme"]
}
],
"summary": {
"overview": "...",
"opportunities": [
{ "title": "...", "pillar": "...", "rationale": "...", "recommended_action": "...", "priority": "high" }
],
"quick_wins": [{ "keyword": "...", "rationale": "..." }]
},
"filtering": {
"seen": 11840,
"kept": 3120,
"rejected_by_embedding": 4210,
"rejected_by_model": 4510,
"judged_by_model": 7630,
"reused_verdicts": 1180
},
"sheets": [
{ "name": "roadmap", "row_count": 1000, "preview": [] },
{ "name": "research", "row_count": 2870, "preview": [] },
{ "name": "competitors", "row_count": 934, "preview": [] },
{ "name": "clusters", "row_count": 288, "preview": [] },
{ "name": "seasonality", "row_count": 962, "preview": [] }
],
"export": {
"format": "xlsx",
"url": "https://.../keyword-research-exports/RUN_ID.xlsx?token=...",
"path": "RUN_ID.xlsx",
"size_bytes": 486331,
"expires_at": "ISO-8601 timestamp"
},
"errors": [],
"meta": {
"candidate_count": 11840,
"kept_count": 1000,
"filtered_out_count": 8720,
"truncated_count": 2120,
"serp_cluster_count": 288,
"pillar_count": 6,
"total_volume": 148900,
"brand_volume": 9100,
"non_brand_volume": 139800,
"ranking_keyword_count": 44,
"gap_keyword_count": 233,
"max_keywords": 1000,
"serp_similarity_threshold": 0.4,
"model": "gpt-5.6-luna",
"provider_cost": {
"provider": "dataforseo",
"total_usd": 0.4127,
"by_endpoint": {
"keywords_data.keywords_for_keywords": { "cost_usd": 0.09, "calls": 1 }
}
},
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Keyword research completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/keyword-research/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/keyword-research/site-descriptionAuth requiredDescribe what a site sells
Reads a site with Exa and writes the paragraph that goes into business_description: what it sells, its categories, its context — in the language of the study. Not a run: it answers in seconds and there is nothing to poll. Costs 1 credit, charged only when an answer comes back. Worth calling before every study, because business_description is the reference every discovered keyword is scored against and an empty one silently ruins the filter.
Status codes
200Answered, or answered empty with a detail.401Missing or invalid API key.402Insufficient credits.422The domain is not usable.503Exa is not configured.
Request body
The country and language decide which language the answer is written in and where it is searched from.
{
"domain": "literie.fr",
"country": "fr",
"language": "fr"
}Response body
An empty description with a detail means Exa found nothing — write the description by hand rather than running a study without one. Nothing is charged in that case.
{
"domain": "literie.fr",
"description": "Literie.fr vend des matelas, sommiers, oreillers...",
"citations": ["https://literie.fr/matelas"],
"credits_charged": 1,
"detail": null
}Example
curl -X POST https://api.searchflow.app/api/v1/keyword-research/site-description \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "domain": "literie.fr", "country": "fr", "language": "fr" }'/api/v1/keyword-research/brand-variantsAuth requiredSuggest brand variants
Lists up to ten ways searchers actually type a brand, so branded queries can be told apart from the rest of the study. Costs 0.5 credit, charged on delivery. Review the list before sending it into a run: a brand whose name is a common word will otherwise mark half the study as branded.
Status codes
200Answered, or answered empty with a detail.401Missing or invalid API key.402Insufficient credits.503OpenAI is not configured.
Request body
domain and business_description are optional and only sharpen the suggestions.
{
"brand": "Bultex",
"domain": "bultex.fr",
"business_description": "string (optional)",
"language": "fr"
}Response body
The plain brand name is never returned — the caller already has it.
{
"brand": "Bultex",
"variants": ["bultex avis", "matelas bultex", "bultex mousse"],
"credits_charged": 0.5,
"detail": null
}Example
curl -X POST https://api.searchflow.app/api/v1/keyword-research/brand-variants \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "brand": "Bultex", "domain": "bultex.fr", "language": "fr" }'/api/v1/workflows/rank-or-create/runsAuth requiredCreate rank or create run
For each query, reads the live SERP and the site's own pages (found with a site: search), then decides whether an existing page should be optimised, a new page written, or nothing done because the site already ranks in the top 3. Costs 2 credits per query and per declared market, debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (no usable query, over 100 queries, or a site scope whose country or language cannot be inferred).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
site accepts a bare domain when the TLD settles both country and language (example.fr). A generic domain (.com) or a multilingual country (.be, .ch, .ca) must declare markets explicitly — the API answers 422 rather than guessing a market the site may not serve. Each declared market is analysed separately and billed separately.
{
"queries": [
{ "query": "string (required)", "volume": 8100 }
],
"site": {
"domain": "example.fr",
"markets": [
{ "path_prefix": "/fr", "country": "be", "language": "fr" },
{ "path_prefix": "/nl", "country": "be", "language": "nl" }
]
},
"research": {
"max_chars_per_page": 4000,
"include_paa": true,
"include_related_searches": true
},
"custom_instructions": "string (optional)"
}Response body
202 Accepted. received_queries echoes how many queries survived deduplication; multiply by the number of markets to get what was billed.
{
"run_id": "uuid",
"workflow": "rank_or_create",
"status": "queued",
"received_queries": 3,
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/rank-or-create/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"queries": [
{ "query": "chaussures de running homme", "volume": 8100 }
],
"site": { "domain": "i-run.fr" }
}'/api/v1/workflows/rank-or-create/runs/{run_id}Auth requiredGet rank or create run
Returns the current state of a run. Poll until status is completed or failed. Every item carries its verdict, the three competitor pages that were analysed, the site's own candidate pages, and the reasoning. priorities orders the actionable verdicts: cheapest work first, then by volume — queries the site already ranks for carry no action and are not in it.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed. verdict is optimize, create, or already_ranking; target_url is always one of the candidate URLs, never an invented one.
{
"run_id": "uuid",
"workflow": "rank_or_create",
"status": "completed",
"received_queries": 1,
"progress": 100,
"result": {
"workflow": "rank_or_create",
"site": {
"markets": [
{ "domain": "i-run.fr", "country": "fr", "language": "fr", "label": "i-run.fr (fr-fr)" }
],
"countries": ["fr"],
"languages": ["fr"],
"is_ecosystem": false
},
"items": [
{
"query": "chaussures de running homme",
"volume": 8100,
"market_key": "i-run.fr [fr-fr]",
"status": "completed",
"verdict": "optimize",
"current_position": 6,
"current_url": "https://www.i-run.fr/chaussures-running-homme",
"search_intent": "Acheter une paire, en comparant les modèles disponibles",
"dominant_format": "category",
"target_url": "https://www.i-run.fr/chaussures-running-homme",
"recommended_page_type": "category",
"angle": "…",
"actions": ["…", "…"],
"confidence": "high",
"reasoning": "…",
"effort": "low",
"priority_rank": 1,
"competitors": [
{
"position": 1,
"url": "https://…",
"domain": "…",
"title": "…",
"extracted": true,
"format": "category",
"intent": "…",
"depth": "…",
"structure": "…"
}
],
"candidates": [
{
"position": 1,
"url": "https://www.i-run.fr/chaussures-running-homme",
"domain": "www.i-run.fr",
"title": "…",
"extracted": true,
"format": "category",
"fit": "same_intent",
"note": "…"
}
],
"error": null
}
],
"priorities": [
{
"rank": 1,
"query": "chaussures de running homme",
"market_key": "i-run.fr [fr-fr]",
"verdict": "optimize",
"effort": "low",
"volume": 8100,
"target_url": "https://www.i-run.fr/chaussures-running-homme",
"recommended_page_type": "category"
}
],
"errors": [],
"meta": {
"query_count": 1,
"market_count": 1,
"analysed_count": 1,
"completed_count": 1,
"failed_count": 0,
"optimize_count": 1,
"create_count": 0,
"already_ranking_count": 0,
"competitors_per_query": 3,
"candidates_per_query": 3,
"model": "gpt-5.6-luna",
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Rank or create completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/rank-or-create/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/coverage-gap-analysis/runsAuth requiredCreate coverage gap analysis run
Compares the site with its competitors keyword by keyword, filters the result against the declared business context, and groups the gaps into themes with a recommended action. Costs 45 credits per 1,000 keyword slots, where a slot is one keyword row the run may read: markets × (competitors + 1) × keywords_per_domain. Debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (a domain whose country or language cannot be inferred, or no competitor and discovery disabled).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
The site, the competitors, and what the business sells. keywords_per_domain is the row ceiling sent to the provider, so it is what the run costs. Naming competitors replaces discovery entirely.
{
"site": {
"domain": "string (bare domain; a .fr settles its own market)",
"path_prefix": "string | null (\"/fr\" when a path separates your markets)",
"country": "string | null (required on a generic domain)",
"language": "string | null (required on a generic or multilingual domain)",
"markets": [
{ "domain": "string", "path_prefix": "string", "country": "string", "language": "string" }
]
},
"competitors": ["string (bare domain, up to 10)"],
"competitor_discovery": {
"enabled": "boolean (default: true; ignored when competitors is non-empty)",
"limit": "integer 1-10 (default: 5) — what a discovery run is billed on"
},
"keywords_per_domain": "integer 100-1000 (default: 500)",
"business_context": {
"description": "string | null",
"product_lines": ["string"],
"services": ["string"],
"priorities": ["string"],
"excluded_topics": ["string (beats a product line on conflict)"]
},
"filters": {
"min_search_volume": "integer (default: 10)",
"max_keyword_difficulty": "integer 1-100 (default: 100)",
"max_position": "integer 1-100 (default: 20)"
},
"relevance": {
"enabled": "boolean (default: true)",
"threshold": "float 0-1 (default: 0.30)"
},
"themes": { "label_with_ai": "boolean (default: true)" }
}Response body
Run envelope. result is null until the workflow completes.
{
"run_id": "uuid",
"workflow": "coverage_gap_analysis",
"status": "queued | running | completed | failed",
"progress": 0,
"current_step": "string | null",
"result": "CoverageGapResult | null",
"errors": [{ "item": "string", "stage": "string", "message": "string" }],
"message": "string | null"
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/coverage-gap-analysis/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"site": { "domain": "example.fr" },
"competitors": ["rival.fr"],
"keywords_per_domain": 500,
"business_context": {
"product_lines": ["soins du visage"],
"excluded_topics": ["matériel médical"]
}
}'/api/v1/workflows/coverage-gap-analysis/runs/{run_id}Auth requiredGet coverage gap analysis run
Returns the current state of a run. Poll until status is completed or failed. Themes are sorted by priority, and the export array carries one flat row per keyword across every market, sorted the same way. A row's recommended_action is the action for that keyword; theme_action is the verdict on the theme it belongs to, which can differ. meta.provider_cost reports what the run actually spent at DataForSEO.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed.
{
"run_id": "uuid",
"workflow": "coverage_gap_analysis",
"status": "completed",
"progress": 100,
"result": {
"workflow": "coverage_gap_analysis",
"site": {
"markets": [{ "domain": "example.fr", "country": "fr", "language": "fr", "label": "example.fr (fr-fr)" }],
"is_ecosystem": false
},
"markets": [
{
"market": { "domain": "example.fr", "country": "fr", "language": "fr" },
"competitors": [
{ "domain": "rival.fr", "source": "provided", "keyword_count": 500 }
],
"themes": [
{
"id": "theme_1",
"label": "Crèmes hydratantes visage",
"status": "absent | behind | leading",
"priority": 62.4,
"keyword_count": 34,
"total_search_volume": 41200,
"gap_search_volume": 38900,
"avg_keyword_difficulty": 27.5,
"absent_count": 30,
"behind_count": 4,
"leading_count": 0,
"competitors": [{ "domain": "rival.fr", "keyword_count": 34 }],
"recommended_action": "create | improve | defend | monitor",
"rationale": "No page of yours ranks on 34 of these keywords …",
"keywords": [
{
"keyword": "crème hydratante visage",
"search_volume": 12100,
"keyword_difficulty": 31,
"status": "absent",
"own_position": null,
"own_url": null,
"own_ranks_off_market": false,
"competitors": [{ "domain": "rival.fr", "position": 3, "url": "https://…" }],
"competitor_count": 1,
"relevance": 0.61,
"priority": 68.2
}
]
}
],
"discarded": {
"count": 87,
"relevance_filter_applied": true,
"keywords": [
{
"keyword": "fauteuil roulant",
"search_volume": 4400,
"reason": "matches_an_excluded_topic",
"relevance": 0.22,
"nearest_anchor": "soins du visage"
}
],
"truncated": false
},
"summary": {
"keyword_count": 640,
"covered_keyword_count": 210,
"absent_keyword_count": 430,
"coverage_rate": 0.3281,
"gap_search_volume": 512300,
"theme_count": 42,
"absent_theme_count": 25,
"behind_theme_count": 12,
"leading_theme_count": 5,
"own_ranked_keywords": 500,
"business_context_applied": true
}
}
],
"export": [
{
"market": "example.fr [fr-fr]",
"theme_id": "theme_1",
"theme": "Crèmes hydratantes visage",
"keyword": "crème hydratante visage",
"search_volume": 12100,
"keyword_difficulty": 31,
"status": "absent",
"own_position": null,
"own_url": null,
"competitor_count": 1,
"competitors": "rival.fr",
"priority": 68.2,
"recommended_action": "create",
"theme_action": "create"
}
],
"errors": [],
"meta": {
"market_count": 1,
"competitor_count": 1,
"keywords_per_domain": 500,
"billed_keyword_slots": 1000,
"relevance_filter_applied": true,
"relevance_threshold": 0.3,
"business_context": { "declared": true, "anchor_count": 3, "excluded_topic_count": 1 },
"export_row_count": 640,
"export_truncated": false,
"provider_cost": {
"provider": "dataforseo",
"total_usd": 0.144,
"by_endpoint": { "labs.ranked_keywords": { "cost_usd": 0.144, "calls": 2 } }
},
"model": "gpt-5.6-luna",
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Coverage gap analysis completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/coverage-gap-analysis/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/local-seo-page/runsAuth requiredCreate local SEO pages run
Writes one landing page per city from the live local pack: title, meta description, H1, a locally anchored introduction, sections chosen for the trade, and an FAQ built from the questions Google reports people asking. The businesses shown on the page are provider records — name, rating, review count, category — rendered by the platform, never written by the model. Costs 3 credits per city, debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (no usable city, no service, over 100 cities, or a section/FAQ range where max is below min).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
service is combined with each city into the search whose local pack is read, so "plombier" plus "Nantes" researches "plombier nantes"; pass keyword to override that per city. context is the only source of local facts other than the research — the page will not state an address, a phone number, opening hours or a firm price, whatever else you send.
{
"cities": [
{
"city": "string (required)",
"keyword": "string (optional, defaults to '<service> <city>')",
"context": "string (optional, districts served, a branch, a radius)",
"geolocation": "string (optional, overrides the run-wide one)"
}
],
"service": "string (required, the trade — 'plombier', 'salle de sport')",
"locale": { "country": "fr", "language": "fr" },
"output_format": "html | text | both",
"sections": { "min": 2, "max": 5 },
"faq": { "min": 3, "max": 6 },
"brand": { "name": "string", "tone_of_voice": "string", "audience": "string" },
"custom_instructions": "string (applies to every page)",
"research": {
"local_businesses": 6,
"competitors": 4,
"extract_competitors": true,
"include_paa": true,
"include_related_searches": true
}
}Response body
202 Accepted. received_cities echoes how many cities survived deduplication — that is the number you were billed for, at 3 credits each.
{
"run_id": "uuid",
"workflow": "local_seo_page",
"status": "queued",
"received_cities": 1,
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/local-seo-page/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"cities": [
{
"city": "Nantes",
"context": "Intervention sur Nantes centre, Chantenay et l Ile de Nantes."
}
],
"service": "plombier",
"locale": { "country": "fr", "language": "fr" },
"sections": { "min": 2, "max": 3 },
"research": { "local_businesses": 5 }
}'/api/v1/workflows/local-seo-page/runs/{run_id}Auth requiredGet local SEO pages run
Returns the current state of a run. Poll until status is completed or failed. Read guardrail before publishing: it reports any address, phone number, email or opening-hours claim found in the generated copy, which the workflow forbids and which nothing in the research could have supplied. clean: false means the page needs a human before it goes live. research.local_businesses is what the page's businesses section must be rendered from — it is provider data, and the model is never asked to reproduce it.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed. Abridged from a real run for 'plombier' in Nantes.
{
"run_id": "uuid",
"workflow": "local_seo_page",
"status": "completed",
"received_cities": 1,
"progress": 100,
"result": {
"workflow": "local_seo_page",
"locale": { "country": "fr", "language": "fr" },
"service": "plombier",
"items": [
{
"city": "Nantes",
"keyword": "plombier nantes",
"status": "completed",
"page": {
"title": {
"text": "Plombier à Nantes : dépannage, fuite et travaux",
"char_count": 47,
"budget": { "min": 45, "max": 60, "unit": "characters", "measured": 47, "within_budget": true }
},
"meta_description": { "text": "À Nantes, trouvez un plombier pour fuite, débouchage ou chauffe-eau…", "char_count": 137 },
"h1": { "text": "Un plombier pour vos travaux à Nantes", "char_count": 37 },
"intro": {
"html": "<p>Une fuite sous l'évier, des WC bouchés…</p>",
"word_count": 112,
"budget": { "min": 110, "max": 190, "unit": "words", "measured": 112, "within_budget": true }
},
"businesses_intro": { "html": "<p>Pour choisir un plombier à Nantes, regardez la note, mais aussi…</p>", "word_count": 52 },
"sections": [
{
"heading": "Quel budget prévoir pour un plombier à Nantes ?",
"html": "<p>À titre indicatif, le marché nantais situe le tarif horaire…</p>",
"word_count": 103,
"budget": { "min": 90, "max": 180, "unit": "words", "measured": 103, "within_budget": true }
}
],
"faq": [
{
"question": "Quel est le prix moyen d'un plombier à Nantes ?",
"answer": "Le tarif horaire moyen observé à Nantes se situe entre 60 et 80 € HT…",
"word_count": 46,
"within_budget": true
}
]
},
"guardrail": { "clean": true, "findings": [] },
"research": {
"local_businesses": [
{
"name": "Ze Plombier - Nantes",
"source": "local_pack",
"rank": 2,
"rating": 4.8,
"reviews": 358,
"category": "Plombier",
"domain": "www.zeplombier.fr",
"url": "https://www.zeplombier.fr/",
"cid": "1512192997907758528",
"descriptor": "Plus de 15 ans en activité · Nantes · Services sur place · Devis en ligne",
"review_quote": "Plombier efficace compétent et très sympathique"
}
],
"people_also_ask": ["Quel est le prix moyen d'un plombier ?"],
"related_searches": ["Plombier Nantes urgence"],
"competitors": [
{ "position": 1, "url": "https://…", "domain": "…", "title": "…", "extracted": true }
]
}
}
],
"errors": [],
"meta": {
"page_count": 1,
"completed_count": 1,
"failed_count": 0,
"output_format": "both",
"model": "gpt-5.6-luna",
"local_businesses_per_page": 5,
"competitors_per_page": 4,
"provider_costs": {
"provider": "dataforseo",
"total_usd": 0.004,
"by_endpoint": {
"serp/google/maps/live/advanced": { "cost_usd": 0.002, "calls": 1 },
"serp/google/organic/live/advanced": { "cost_usd": 0.002, "calls": 1 }
}
},
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Local SEO pages completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/local-seo-page/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/informational-content/runsAuth requiredCreate informational content run
Reads the live SERP for each keyword, decides which article format that query rewards — tutorial, guide, comparison, definition, news, opinion — then produces a structured outline and writes the article from it. Set subtype to force a format instead. Costs 4 credits per article plus 1 per 1,000 words of the length.max_words ceiling (1,400 words when the length block is omitted), debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (no usable keyword, over 50 articles, or max_words below min_words).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
subtype accepts auto (the default), tutorial, guide, comparison (listing and listicle are aliases), definitional, news or opinion — at run level, per article, or both. Passing article_url reads the current page and rewrites it instead of writing from scratch. The length block is what the run is billed on; omit it and each format uses its own band, capped at 1,400 words.
{
"articles": [
{
"keyword": "string (required, the search readers use)",
"secondary_keywords": ["string"],
"subtype": "auto | tutorial | guide | comparison | definitional | news | opinion",
"article_url": "string (optional, rewrite an existing page)",
"context": "string (optional, notes for this article)"
}
],
"locale": { "country": "fr", "language": "fr" },
"output_format": "html | text | both",
"subtype": "auto",
"length": { "min_words": 800, "max_words": 1200 },
"tone": "string (optional)",
"brand": { "name": "string", "tone_of_voice": "string", "audience": "string" },
"custom_instructions": "string (applies to the plan and the article)",
"research": { "competitors": 5, "max_chars_per_page": 4000 }
}Response body
202 Accepted. received_articles echoes how many keywords survived deduplication — that is the number you were billed for.
{
"run_id": "uuid",
"workflow": "informational_content",
"status": "queued",
"received_articles": 1,
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/informational-content/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"articles": [
{
"keyword": "comment choisir un crm",
"secondary_keywords": ["logiciel crm", "crm pme"]
}
],
"locale": { "country": "fr", "language": "fr" },
"length": { "min_words": 800, "max_words": 1200 }
}'/api/v1/workflows/informational-content/runs/{run_id}Auth requiredGet informational content run
Returns the current state of a run. Poll until status is completed or failed. Every article reports the format that was chosen, the SERP signals that chose it and the score of every runner-up, so the decision can be audited rather than taken on trust. covered_questions lists only the People Also Ask questions matched back against the panel — never a question the model claimed but the panel never asked — so it is empty when Google rendered no panel for that query, which is common. On comparison articles the table is returned as structure plus ready-to-paste HTML in comparison_table.html, separate from the body: the body explains what the table cannot rather than repeating it row by row, so publish both.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed. comparison_table and selection_criteria are present only on comparison articles.
{
"run_id": "uuid",
"workflow": "informational_content",
"status": "completed",
"received_articles": 1,
"progress": 100,
"result": {
"workflow": "informational_content",
"locale": { "country": "fr", "language": "fr" },
"items": [
{
"keyword": "comment choisir un crm",
"status": "completed",
"subtype": {
"subtype": "tutorial",
"label": "Tutorial",
"source": "serp",
"confidence": 0.58,
"signals": [
"the keyword reads as a tutorial (\"comment\")",
"3 of 5 ranking titles are shaped as a tutorial"
],
"scores": { "tutorial": 5.0, "guide": 2.0 }
},
"title": "Comment choisir un CRM : la méthode en 6 étapes",
"meta_description": "…",
"h1": "Choisir un CRM : la méthode complète",
"outline": {
"angle": "…",
"reader": "…",
"sections": [
{
"heading": "Définir vos besoins avant de comparer",
"angle": "…",
"key_points": ["…"],
"questions": ["…"],
"subheadings": ["…"]
}
],
"questions_to_cover": ["…"]
},
"body": {
"html": "<p>…</p><h2>…</h2><ol><li>…</li></ol>",
"word_count": 1043,
"headings": ["Définir vos besoins avant de comparer"]
},
"length": { "min_words": 800, "max_words": 1200, "measured": 1043, "within_budget": true },
"covered_questions": ["Quel CRM pour une PME ?"],
"research": {
"competitors": [
{ "position": 1, "url": "https://…", "domain": "…", "title": "…", "extracted": true }
],
"people_also_ask": ["…"],
"related_searches": ["…"]
}
}
],
"errors": [],
"meta": {
"article_count": 1,
"completed_count": 1,
"failed_count": 0,
"output_format": "html",
"model": "gpt-5.6-luna",
"competitors_per_article": 5,
"billed_max_words": 1200,
"subtypes": ["tutorial"],
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Informational content completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/informational-content/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/technical-crawl/runsAuth requiredCreate technical crawl run
Crawls a site and returns an audit ranked by what actually costs traffic: underused titles first, then genuinely broken structural tags, hreflang, robots.txt, facets, sitemap, internal linking, and JavaScript rendering. Cosmetic checks are returned as counts, never as findings. Costs 4 credits per run plus 20 per 1,000 pages of crawl.max_pages plus 0.2 per rendered page — the crawl BUDGET is billed, not the pages found. Debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (invalid domain, max_pages over 10,000, a relative start_url, or a generic/multilingual domain whose markets were not declared).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
A .fr implies one country and one language, so `site: { domain }` is enough. A .be, a .ch or a .com does not — declare country and language, or list markets, otherwise the request is refused rather than reporting on a market the site may not serve. Rendering a page with JavaScript costs eleven times crawling it, which is why javascript_rendering is a sample: percent of the budget, capped at 200 pages.
{
"site": {
"domain": "string (required, no scheme, no www)",
"country": "fr",
"language": "fr",
"markets": [
{ "domain": "example.be", "path_prefix": "/fr", "language": "fr" },
{ "domain": "example.be", "path_prefix": "/nl", "language": "nl" }
]
},
"crawl": {
"max_pages": 1000,
"start_url": "string (optional, absolute http(s) URL)",
"respect_sitemap": true,
"allow_subdomains": false
},
"javascript_rendering": {
"mode": "auto | always | off",
"percent": 5,
"max_pages": 200
},
"analysis": {
"language": "fr",
"ecommerce": false,
"custom_instructions": "string (optional)"
}
}Response body
202 Accepted. The billed page budget is echoed back in the completed run under meta.crawl_budget, alongside meta.pages_crawled — the pages actually read.
{
"run_id": "uuid",
"workflow": "technical_crawl",
"status": "queued",
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/technical-crawl/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"site": { "domain": "example.fr" },
"crawl": { "max_pages": 1000 },
"javascript_rendering": { "mode": "auto", "percent": 5 },
"analysis": { "language": "fr", "ecommerce": true }
}'/api/v1/workflows/technical-crawl/runs/{run_id}Auth requiredGet technical crawl run
Returns the current state of a run. A crawl runs at the provider's pace — tens of minutes for a large site — so poll on a 5-second interval until status is completed or failed. Every finding carries the measurement it rests on in `evidence`, so a claim can be checked without rerunning the crawl. The crawl itself is stored under `crawl_snapshot` and can be reused by another workflow for 30 days without crawling again.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. `findings` is ordered by the audit's fixed priority, not by how many pages each issue touches. `low_value_issue_counts` holds the cosmetic checks as totals — they are deliberately never expanded into findings.
{
"run_id": "uuid",
"workflow": "technical_crawl",
"status": "completed",
"progress": 100,
"result": {
"workflow": "technical_crawl",
"site": { "domain": "example.fr", "is_ecosystem": false },
"crawl": {
"domain": "example.fr",
"pages_crawled": 842,
"robots_txt_present": true,
"sitemap_present": true,
"onpage_score": 82.4,
"javascript_frameworks": ["Next.js", "React"]
},
"verdict": "41 commercial pages share one title and are the biggest thing left on the table.",
"findings": [
{
"category": "underused_titles",
"priority": 1,
"severity": "high",
"title": "41 category pages share the title « Nos produits »",
"what_is_happening": "41 indexable pages reachable in two clicks carry the same title.",
"why_it_costs_traffic": "Google cannot tell them apart, so it picks one and the other 40 compete for nothing.",
"what_to_do": "Template the title from the category name and its qualifier.",
"affected_pages": 41,
"example_urls": ["https://example.fr/c/bottes"]
}
],
"quick_wins": ["Template the 41 duplicate category titles."],
"cosmetic_summary": "312 titles run past Google's display width. Leave them until the findings above are done.",
"low_value_issue_counts": { "title_too_long": 312, "images_without_alt": 1204 },
"evidence": [
{
"category": "underused_titles",
"severity": "high",
"headline": "41 important pages share 1 title between them",
"affected_pages": 41,
"sample_urls": ["https://example.fr/c/bottes"],
"detail": { "groups": [{ "title": "Nos produits", "page_count": 41 }] }
}
],
"javascript_rendering": {
"mode": "auto",
"percent": 5,
"pages_billed": 50,
"pages_rendered": 50,
"frameworks_detected": ["Next.js"],
"note": "Rendered a random sample of 50 pages (5% of the crawl budget)",
"comparisons": [
{ "url": "https://example.fr/c/bottes", "raw_word_count": 41, "rendered_word_count": 780, "word_gain": 739, "word_gain_ratio": 0.947 }
]
},
"crawl_snapshot": {
"id": "b1e0c4f2-…",
"schema_version": 1,
"storage_bucket": "crawl-snapshots",
"storage_path": "example.fr/b1e0c4f2-….json",
"pages": 842,
"internal_links": 19304,
"expires_at": "ISO-8601 timestamp"
},
"errors": [],
"meta": {
"pages_crawled": 842,
"crawl_budget": 1000,
"finding_count": 9,
"model": "gpt-5.6-luna",
"provider_cost": { "provider": "dataforseo", "total_usd": 0.2085 },
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Technical crawl completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/technical-crawl/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/discover-topics/runsAuth requiredCreate Discover topics run
Expands an editorial theme into the searches a news monitor would watch, anchors them on real search volume, reads the Google News tab for each, and returns ranked topics with their angle, headline, reasoning and timing window. Costs 4 credits per theme plus 0.6 per exploration keyword, debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (no usable theme, over 20 themes, or an exploration value outside its range).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
exploration.keywords is the only field that changes the price. exclude is applied twice: when choosing which keywords to explore, and again when topics are proposed. Google News is read for the locale.country edition, so the country must be one this workflow supports — an unsupported one is rejected before anything is spent.
{
"themes": [
{
"theme": "string (required, an editorial subject, not a keyword)",
"exclude": ["string (already covered or out of scope)"],
"context": "string (optional, what you publish and for whom)"
}
],
"locale": { "country": "fr", "language": "fr" },
"exploration": {
"keywords": 10,
"topics": 12,
"read_articles": true,
"max_chars_per_article": 2000
},
"custom_instructions": "string (applies to every theme)"
}Response body
202 Accepted. received_themes echoes how many themes survived deduplication — that is the number you were billed for.
{
"run_id": "uuid",
"workflow": "discover_topics",
"status": "queued",
"received_themes": 1,
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/discover-topics/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"themes": [
{ "theme": "nutrition sportive", "exclude": ["créatine"] }
],
"locale": { "country": "fr", "language": "fr" },
"exploration": { "keywords": 10, "topics": 12 }
}'/api/v1/workflows/discover-topics/runs/{run_id}Auth requiredGet Discover topics run
Returns the current state of a run. Poll until status is completed or failed. Topics come back ordered by discover_score. The freshness block is computed from the article timestamps rather than judged by a model, so it is a measurement, not an opinion.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed.
{
"run_id": "uuid",
"workflow": "discover_topics",
"status": "completed",
"received_themes": 1,
"progress": 100,
"result": {
"workflow": "discover_topics",
"locale": { "country": "fr", "language": "fr" },
"items": [
{
"theme": "nutrition sportive",
"status": "completed",
"saturated_angles": [
{ "angle": "Les protéines après l'effort", "evidence": "6 articles en 7 jours, tous grand public" }
],
"open_angles": [
{ "angle": "Le coût réel d'une supplémentation", "why_open": "Cité nulle part dans la couverture lue" }
],
"hook_patterns": ["Chiffre + démenti d'une idée reçue"],
"topics": [
{
"title": "Ce que coûte vraiment une année de compléments alimentaires",
"angle": "Le budget, poste par poste, plutôt que l'efficacité",
"why_it_works": "Aucune des pièces lues ne chiffre la dépense",
"timing": "evergreen",
"timing_note": "Tenable toute l'année, pic en janvier",
"discover_score": 78,
"evidence": "12 articles lus, aucun ne mentionne de prix",
"keywords": ["prix compléments alimentaires", "budget nutrition sportive"]
}
],
"research": {
"keywords": [{ "keyword": "nutrition sportive", "search_volume": 22200 }],
"articles_found": 34,
"articles_read": 21,
"freshness": {
"dated_articles": 31,
"median_age_hours": 96.4,
"published_last_24h": 3,
"published_last_7d": 18,
"published_last_30d": 27
},
"articles": [
{
"title": "…",
"url": "https://…",
"domain": "…",
"position": 1,
"published_at": "2026-08-11T07:30:00+00:00",
"age_hours": 29.5,
"extracted": true
}
]
}
}
],
"errors": [],
"meta": {
"theme_count": 1,
"completed_count": 1,
"failed_count": 0,
"exploration_keywords": 10,
"topics_per_theme": 12,
"model": "gpt-5.6-luna",
"completed_at": "ISO-8601 timestamp",
"provider_costs": { "provider": "dataforseo", "total_usd": 0.038 }
}
},
"errors": [],
"message": "Discover topics completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/discover-topics/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/discover-article/runsAuth requiredCreate Discover article run
Writes a headline, standfirst and article aimed at the Google Discover feed, after reading what is already published on the subject. Always returns a ready-to-paste cover image prompt; renders the image only when image.generate is true. Costs a flat 5 credits per article, 14 with the image, debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (no usable topic, over 50 articles, over 5 source_urls, or an inverted word range).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
topic is a subject, not a finished headline — the headline is written for you, and a title produced by discover-topics can be passed straight through. angle is what stops the piece being the generic treatment everyone else published. notes are stated as fact and never invented. Only image.generate changes the price.
{
"articles": [
{
"topic": "string (required, the subject)",
"angle": "string (optional, what this piece does that others do not)",
"notes": "string (optional, your own facts and figures)",
"source_urls": ["string (optional, up to 5, read before writing)"]
}
],
"locale": { "country": "fr", "language": "fr" },
"output_format": "html | text | both",
"length": { "min_words": 700, "max_words": 1100 },
"research": { "articles_read": 5, "max_chars_per_article": 3000 },
"image": { "generate": false },
"brand": { "name": "string", "tone_of_voice": "string", "audience": "string" },
"custom_instructions": "string (applies to every article)"
}Response body
202 Accepted. received_articles echoes how many topics survived deduplication — that is the number you were billed for.
{
"run_id": "uuid",
"workflow": "discover_article",
"status": "queued",
"received_articles": 1,
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/discover-article/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"articles": [
{
"topic": "Le padel sature les créneaux du soir",
"angle": "L'\''économie des clubs plutôt que la pratique"
}
],
"locale": { "country": "fr", "language": "fr" },
"image": { "generate": true }
}'/api/v1/workflows/discover-article/runs/{run_id}Auth requiredGet Discover article run
Returns the current state of a run. Poll until status is completed or failed. cover_image.prompt is always present; cover_image.url appears only when the image was rendered, and points at a public, permanent object you can reference straight from a CMS. why_discover is written for the publisher, not the reader: it says why the piece can be picked up and what would weaken it.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed.
{
"run_id": "uuid",
"workflow": "discover_article",
"status": "completed",
"received_articles": 1,
"progress": 100,
"result": {
"workflow": "discover_article",
"locale": { "country": "fr", "language": "fr" },
"items": [
{
"topic": "Le padel sature les créneaux du soir",
"angle": "L'économie des clubs plutôt que la pratique",
"status": "completed",
"title": {
"text": "Pourquoi une heure de padel à 20h coûte deux fois plus qu'à 14h",
"char_count": 71,
"budget": { "min": 45, "max": 85, "unit": "characters", "measured": 71, "within_budget": true }
},
"dek": {
"text": "Les clubs ont fait leurs comptes : le créneau du soir finance le reste de la journée.",
"char_count": 84,
"budget": { "min": 100, "max": 220, "unit": "characters", "measured": 84, "within_budget": false }
},
"body": {
"html": "<p>…</p><h2>…</h2><ul><li>…</li></ul>",
"word_count": 912,
"headings": ["…"],
"budget": { "min": 700, "max": 1100, "unit": "words", "measured": 912, "within_budget": true }
},
"cover_image": {
"prompt": "A wide 16:9 photograph of a floodlit indoor padel court at night…",
"alt": "Court de padel éclairé, joueurs en contre-jour",
"generated": true,
"url": "https://<project>.supabase.co/storage/v1/object/public/discover-images/<run_id>/0.jpg",
"storage_path": "<run_id>/0.jpg",
"size": "2048x1152",
"quality": "medium",
"model": "gpt-image-2",
"bytes": 412883
},
"why_discover": "Un angle économique que la couverture existante n'aborde pas…",
"research": {
"articles_found": 5,
"articles_read": 4,
"sources_read": 0,
"freshness": { "dated_articles": 5, "median_age_hours": 52.0 },
"articles": [{ "title": "…", "url": "https://…", "domain": "…", "position": 1 }]
}
}
],
"errors": [],
"meta": {
"article_count": 1,
"completed_count": 1,
"failed_count": 0,
"images_generated": 1,
"output_format": "html",
"model": "gpt-5.6-luna",
"image_model": "gpt-image-2",
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Discover article completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/discover-article/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/cannibalization-analysis/runsAuth requiredCreate cannibalization analysis run
Reads the live SERP of each query, finds the pages of your scope ranking on it, compares them semantically, and separates real cannibalization from a SERP that simply mixes intents. mode is single_site for one domain or cross_site for several domains of one group. Costs 1 credit per query, 1.5 with recommendations, debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed: a domain whose country or language cannot be inferred, a mode that contradicts the scope (cross_site on one domain, single_site on several), a query naming a market the scope does not declare, or over 500 queries.503Supabase or Redis not configured.500Failed to create workflow run.
Request body
site declares the markets analysed. A market is a country, a language, and the part of the site serving it — example.be/fr and example.be/nl are two markets and never cannibalize each other, because they are never shown to the same searcher. A market is identified by its host, so a subdomain like emploi.example.com must be declared as its own market. Leave country and language out when the ccTLD says them (example.fr); a generic domain (.com) is refused rather than guessed. Each query is read on one market: name it with market, or omit it to use the first declared market.
{
"mode": "single_site | cross_site",
"site": {
"markets": [
{ "domain": "example.be", "path_prefix": "/fr", "language": "fr" },
{ "domain": "example.be", "path_prefix": "/nl", "language": "nl" }
]
},
"queries": [
{
"query": "string (required)",
"volume": 8100,
"market": "example.be/fr (optional, defaults to the first market)"
}
],
"detection": {
"similarity_threshold": 0.82,
"max_pages_per_query": 4,
"max_chars_per_page": 2000
},
"recommendations": { "enabled": true }
}Response body
202 Accepted. received_queries echoes how many (query, market) pairs survived deduplication — that is the number you were billed for.
{
"run_id": "uuid",
"workflow": "cannibalization_analysis",
"status": "queued",
"received_queries": 2,
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/cannibalization-analysis/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mode": "single_site",
"site": { "domain": "example.fr" },
"queries": [
{ "query": "assurance auto", "volume": 8100 }
],
"recommendations": { "enabled": true }
}'/api/v1/workflows/cannibalization-analysis/runs/{run_id}Auth requiredGet cannibalization analysis run
Returns the current state of a run. Poll until status is completed or failed. groups is the actionable list, most urgent first: confirmed cannibalizations before groups whose pages could not be read, then by the traffic exposed. A group whose verdict is mixed_intent is not a problem — Google is answering two intents and both pages belong there. cross_market_conflicts is reported apart and is never cannibalization: it means pages written for two different languages share one SERP, which is a hreflang or geotargeting defect. unassigned_urls lists pages on your domain that no declared market owns, usually a subdomain or a path the scope forgot.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. similarity is the lowest pairwise cosine similarity inside the group, evidence says whether that comparison read the pages themselves or only their SERP titles and snippets, and estimated_monthly_clicks_at_risk is the traffic riding on the query — not a measured loss, and zero on a mixed_intent group, where nothing is at risk. scope_urls lists every page of your scope found on the SERP; actions are only ever reported inside a group, on the pages a decision was actually made about.
{
"run_id": "uuid",
"workflow": "cannibalization_analysis",
"status": "completed",
"received_queries": 1,
"progress": 100,
"result": {
"workflow": "cannibalization_analysis",
"mode": "cross_site",
"site": {
"markets": [
{ "domain": "sncf-connect.com", "country": "fr", "language": "fr", "label": "sncf-connect.com (fr-fr)" },
{ "domain": "sncf.com", "subdomain": "emploi", "country": "fr", "language": "fr", "label": "emploi.sncf.com (fr-fr)" }
],
"domains": ["sncf-connect.com", "sncf.com"],
"countries": ["fr"],
"languages": ["fr"],
"is_ecosystem": true
},
"queries": [
{
"query": "sncf",
"market": "sncf-connect.com [fr-fr]",
"country": "fr",
"language": "fr",
"volume": 1500000,
"status": "completed",
"scope_urls": [
{ "url": "https://www.sncf-connect.com/", "position": 1, "title": "SNCF Connect …", "market": "sncf-connect.com [fr-fr]", "language": "fr", "country": "fr", "content_source": "page_content" },
{ "url": "https://emploi.sncf.com/", "position": 5, "title": "Emploi groupe SNCF …", "market": "emploi.sncf.com [fr-fr]", "language": "fr", "country": "fr", "content_source": "page_content" }
],
"group_ids": ["group_1"],
"cross_market_conflicts": [],
"unassigned_urls": []
}
],
"groups": [
{
"id": "group_1",
"query": "sncf",
"market": "sncf-connect.com [fr-fr]",
"language": "fr",
"volume": 1500000,
"kind": "cross_site",
"verdict": "mixed_intent",
"similarity": 0.5401,
"evidence": "page_content",
"domains": ["sncf-connect.com", "sncf.com"],
"best_position": 1,
"estimated_monthly_clicks_at_risk": 0.0,
"keeper_url": "https://www.sncf-connect.com/",
"rationale": "Les intentions sont distinctes. …",
"pages": [
{ "url": "https://www.sncf-connect.com/", "position": 1, "action": "keep", "reason": "…" },
{ "url": "https://emploi.sncf.com/", "position": 5, "action": "differentiate", "reason": "…" }
]
}
],
"errors": [],
"meta": {
"query_count": 1,
"analyzed_count": 1,
"failed_count": 0,
"group_count": 1,
"cannibalization_count": 0,
"mixed_intent_count": 1,
"undetermined_count": 0,
"cross_site_group_count": 1,
"cross_market_conflict_count": 0,
"similarity_threshold": 0.82,
"recommendations_enabled": true,
"max_pages_per_query": 4,
"model": "gpt-5.6-luna",
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Cannibalization analysis completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/cannibalization-analysis/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/internal-linking-analysis/runsAuth requiredCreate internal linking analysis run
Turns a stored crawl into a plan of internal links to add: from which page, to which page, with which anchor, and why. It never crawls — pass the run_id of a technical crawl you already ran and its snapshot is reused for free for 30 days. The link graph (PageRank, orphans, dead ends, anchors) is always measured over the whole crawl; analysis.max_pages bounds only the semantic half, which is the half that costs money. Costs 3 credits per run plus 30 credits per 1,000 pages of analysis.max_pages, plus 0.12 credit per page when analysis.content_source is page_text — 33 credits for a 1,000-page analysis, 153 with the page text — debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed (neither crawl.run_id nor crawl.snapshot_id given, a malformed uuid, max_pages outside 1-10,000, max_links outside 1-300, min_similarity outside 0-1, more than 100 priority_urls, or a priority URL that is not absolute).503Supabase or Redis not configured.500Failed to create workflow run.
Request body
crawl.run_id is the usual way in — it is what the technical crawl endpoint returned to you; crawl.snapshot_id addresses one snapshot directly. Set analysis.max_pages to at least the number of pages the crawl found, or the plan covers only the most important part of the site. content_source 'page_text' buys the real text of every analysed page from Exa and is worth it when titles are templated or thin; 'crawl' compares titles, H1s, the URL and the anchors pointing at each page, and costs nothing extra. priority_urls must match the URLs in the crawl exactly.
{
"crawl": {
"run_id": "uuid (a completed technical crawl run)",
"snapshot_id": "uuid (optional, instead of run_id)"
},
"analysis": {
"max_pages": "integer (optional, default 1000, 1 to 10000, this is what you are billed for)",
"content_source": "crawl | page_text (optional, default crawl)",
"min_similarity": "number (optional, default 0.35, 0 to 1)",
"language": "string (optional, default en)",
"custom_instructions": "string (optional)"
},
"recommendations": {
"max_links": "integer (optional, default 50, 1 to 300, does not change the price)",
"priority_urls": ["string (optional, up to 100 absolute URLs)"]
}
}Response body
202 Accepted. There is no received_* field: the billed quantity is the page budget you declared, and the result echoes it in coverage.page_budget beside the pages the crawl actually held.
{
"run_id": "uuid",
"workflow": "internal_linking_analysis",
"status": "queued",
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/internal-linking-analysis/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"crawl": { "run_id": "b1e0c4f2-6f5b-4a7c-9a1e-2f3c4d5e6f70" },
"analysis": { "max_pages": 500, "language": "fr" },
"recommendations": {
"max_links": 40,
"priority_urls": ["https://example.fr/pneus-hiver"]
}
}'/api/v1/workflows/internal-linking-analysis/runs/{run_id}Auth requiredGet internal linking analysis run
Returns the current state of a run. Poll until status is completed or failed. recommendations is the deliverable, ordered by an impact score computed from the graph and the embeddings rather than by the model — two runs of the same crawl produce the same order. Read coverage before quoting any figure: partial says the semantic pass covered only part of the site, and orphan_confidence is low when the crawl stopped on one of its own budgets — its pages or its links — because a page at the edge can then look orphaned only because whatever links to it was never fetched.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. result is populated when status is completed.
{
"run_id": "uuid",
"workflow": "internal_linking_analysis",
"status": "completed",
"progress": 100,
"result": {
"workflow": "internal_linking_analysis",
"site": { "domain": "example.fr" },
"crawl": {
"snapshot_id": "b1e0c4f2-…",
"run_id": "7c41a0d2-…",
"crawled_at": "2026-09-01T09:14:22+00:00",
"pages": 184,
"internal_links": 4172,
"truncated": false
},
"coverage": {
"crawled_pages": 184,
"analysed_pages": 184,
"page_budget": 500,
"content_source": "crawl",
"partial": false,
"crawl_truncated": false,
"orphan_confidence": "high"
},
"verdict": "Le maillage reste concentré sur la page d'accueil : 61 % du PageRank interne tient dans un dixième des pages, et la gamme hiver n'est reliée à rien.",
"priorities": [
{
"title": "Relier la gamme hiver à elle-même",
"what_is_happening": "Les pneus hiver et les chaînes neige ne se citent jamais.",
"why_it_matters": "Aucune des deux pages ne profite de l'autorité de l'autre.",
"what_to_do": "Ajouter un lien croisé dans le paragraphe sur le montage.",
"affected_pages": 2
}
],
"anchor_guidance": "38 liens internes pointent vers une même page avec « en savoir plus ». Décrire la destination.",
"graph": {
"pages": 184,
"internal_links": 4172,
"orphan_pages": 12,
"dead_end_pages": 3,
"deep_pages": 27,
"pagerank_top_decile_share": 0.61,
"priority_pagerank_share": 0.014,
"priority_pages_found": 1,
"median_inbound_links": 6,
"average_click_depth": 2.8,
"broken_internal_links": 4,
"nofollow_internal_links": 18,
"link_hubs": 1
},
"orphan_pages": [
{
"url": "https://example.fr/guides/montage",
"title": "Monter ses pneus hiver",
"pagerank_share": 0.00081,
"click_depth": 3,
"inbound_links": 0,
"outbound_links": 4,
"is_orphan": true,
"is_dead_end": false,
"is_priority": false
}
],
"dead_end_pages": [],
"top_pages": [],
"anchor_issues": [
{
"target_url": "https://example.fr/pneus-hiver",
"kind": "over_optimised_anchor",
"detail": "34 of 38 internal links use the same anchor, 'pneus hiver'",
"occurrences": 34
}
],
"weak_links": [
{
"source_url": "https://example.fr/pneus-hiver",
"target_url": "https://example.fr/mentions-legales",
"anchor": "mentions légales",
"similarity": 0.04
}
],
"clusters": [
{
"id": "cluster_1",
"pages": 14,
"internal_link_share": 0.06,
"outgoing_links": 212,
"orphan_pages": 3,
"pagerank_share": 0.041,
"example_urls": ["https://example.fr/pneus-hiver"],
"titles": ["Pneus hiver"]
}
],
"recommendations": [
{
"source_url": "https://example.fr/pneus-hiver",
"target_url": "https://example.fr/chaines-neige",
"source_title": "Pneus hiver",
"target_title": "Chaînes neige",
"anchor": "chaînes neige homologuées",
"placement": "dans le paragraphe sur la réglementation montagne",
"rationale": "Envoie l'acheteur hiver vers l'équipement complémentaire qu'il cherche ensuite.",
"impact": 71.4,
"similarity": 0.78,
"reasons": [
"the two pages are 78% semantically related",
"the destination receives only 2 internal links",
"the source is in the top decile of internal PageRank, so it has authority to pass"
]
}
],
"errors": [],
"meta": {
"analysed_pages": 184,
"page_budget": 500,
"content_source": "crawl",
"pages_with_text": 0,
"recommendation_count": 40,
"model": "gpt-5.6-luna",
"embedding_model": "text-embedding-3-small",
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Internal linking analysis completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/internal-linking-analysis/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/workflows/content-decay/runsAuth requiredCreate content decay run
Finds the pages losing search traffic slowly rather than suddenly. Reads the account's own Search Console history in 28-day buckets, compares each one against the same bucket 364 days earlier so seasonality cancels out, requires a monotone decline rather than a gap between two points, and separates lost impressions from lost click-through at a constant rank — the two have different fixes. Requires a Google connection with the Search Console scope and a property bound to each market of the site, both set up in the dashboard under Integrations; an account without one still receives 202 here and the run then fails with 'No active Google connection' and a full refund. Costs 0.3 credits per page of analysis.max_pages plus 0.05 per SERP check of serp_verification.max_queries — 16 credits at the defaults — debited on creation and refunded automatically if the run fails.
Status codes
202Run accepted and queued.401Missing or invalid API key.402Insufficient credits for this run.422Request validation failed: a domain whose country or language cannot be inferred, analysis.months outside 3-12, analysis.max_pages outside 1-500, analysis.min_decline outside 0.05-0.95, analysis.min_trend_strength outside 0-1, serp_verification.max_queries outside 0-200, or an end_date that is not YYYY-MM-DD.503Supabase or Redis not configured.500Failed to create workflow run.
Request body
site declares the markets analysed. gsc_site_url names the Search Console property to read for the run — list the readable ones with the MCP tool searchflow_list_search_console_properties or the List Google properties endpoint, and ask the user which one when there are several. Without it, each market reads the property bound to it under Integrations. Either way a market no property covers is reported as an error and the other markets still run. analysis carries the window and the three tests a page has to clear; serp_verification carries the only paid provider call of the reading half. Everything is optional except site: the defaults are the ones described here. analysis.months is capped by Google rather than by us — Search Console keeps 16 months, and once the 364-day year-on-year shift is taken off, four 28-day buckets is the longest window that has a baseline at all. A larger value is served as four, reported in errors and echoed in meta.buckets_requested, because comparing against a baseline Google no longer holds reads as decay on every page of the site.
{
"site": {
"domain": "example.fr"
},
"connection_id": "uuid (optional, defaults to the first active connection)",
"gsc_site_url": "sc-domain:example.fr (optional, overrides the per-market bindings)",
"analysis": {
"months": 4,
"end_date": "2026-09-01 (optional, defaults to three days ago)",
"max_pages": 50,
"min_baseline_clicks": 100,
"min_decline": 0.2,
"min_trend_strength": 0.4
},
"serp_verification": {
"enabled": true,
"max_queries": 20
}
}Response body
202 Accepted. There is no received_* field: the billed quantities are the two ceilings you sent, not the length of a list, and both are echoed in result.meta once the run completes.
{
"run_id": "uuid",
"workflow": "content_decay",
"status": "queued",
"progress": 0,
"current_step": null,
"result": null,
"errors": [],
"message": null
}Example
curl -X POST https://api.searchflow.app/api/v1/workflows/content-decay/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"site": { "domain": "example.fr" },
"analysis": { "months": 4, "max_pages": 50 },
"serp_verification": { "enabled": true, "max_queries": 20 }
}'/api/v1/workflows/content-decay/runs/{run_id}Auth requiredGet content decay run
Returns the current state of a run. Poll until status is completed or failed. pages holds the eroding pages, worst first by clicks lost — absolute clicks, not percentage, so a page down 12% on 40,000 clicks outranks one down 60% on 200. diagnosis is the one field to read first: position_loss means you rank lower, serp_ctr_loss means the same rank and the same impressions now yield fewer clicks (an AI overview, a new block, a better promise above you), demand_decline means nothing is broken and there are simply fewer searches. incidents is reported apart and is never decay: a cliff is a deindexation, a penalty or an unpublished page, and lost is a page already at zero. trend.score is Kendall's tau on the year-on-year ratios, where -1 is a decline at every bucket. Read errors before the pages: a window Search Console failed to return is excluded from the comparison rather than counted as zero traffic, and it says so there — the pages of that property are then scored on the buckets that were read.
Status codes
200Run found.401Missing or invalid API key.404Run not found or not accessible.503Supabase not configured.
Response body
Same envelope as the create endpoint. recommendation.action is one of refresh_content, restructure_for_intent, internal_linking, backlinks, consolidate, retire_serp_shifted, accept_demand_decline or monitor — the last two are answers that ask for no work, and they are the right answer often enough that a report without them would be wrong half the time. It is null when the model call failed; the diagnosis above it still stands.
{
"run_id": "uuid",
"workflow": "content_decay",
"status": "completed",
"progress": 100,
"result": {
"workflow": "content_decay",
"site": {
"markets": [
{ "domain": "example.fr", "country": "fr", "language": "fr", "label": "example.fr (fr-fr)" }
],
"domains": ["example.fr"],
"countries": ["fr"],
"languages": ["fr"],
"is_ecosystem": false
},
"connection": {
"id": "3f9c…",
"google_account_email": "seo@example.fr",
"properties": [
{ "market": "example.fr [fr-fr]", "site_url": "sc-domain:example.fr" }
]
},
"period": {
"bucket_days": 28,
"buckets": 4,
"current_start": "2026-05-13",
"current_end": "2026-09-01",
"baseline_start": "2025-05-14",
"baseline_end": "2025-09-02"
},
"pages": [
{
"url": "https://example.fr/guide-assurance-auto",
"market": "example.fr [fr-fr]",
"country": "fr",
"language": "fr",
"status": "decaying",
"diagnosis": "serp_ctr_loss",
"reason": "clicks down -26% year on year with a trend score of -1.00 over 4 comparable buckets",
"clicks": { "current": 592.0, "baseline": 800.0, "change": -0.26, "lost": 208.0 },
"impressions": { "current": 16000.0, "baseline": 16000.0, "change": 0.0 },
"ctr": { "current": 0.037, "baseline": 0.05, "change": -0.26 },
"position": { "current": 3.1, "baseline": 3.0, "delta": 0.1 },
"trend": {
"score": -1.0,
"comparable_buckets": 4,
"yoy_ratios": [0.9, 0.8, 0.7, 0.56],
"buckets": [
{ "start": "2026-05-13", "end": "2026-06-09", "clicks": 180.0, "baseline_clicks": 200.0, "yoy_ratio": 0.9 }
]
},
"lost_queries": [
{
"query": "assurance auto pas chere",
"clicks_baseline": 260.0,
"clicks_current": 120.0,
"clicks_lost": 140.0,
"position_baseline": 3.0,
"position_current": 3.2,
"position_delta": 0.2,
"verified": true
}
],
"serp_checks": [
{
"query": "assurance auto pas chere",
"own_position": 4,
"results": [
{ "position": 1, "domain": "lelynx.fr", "title": "Comparateur assurance auto — devis en 3 min" }
],
"people_also_ask": ["Quelle est l'assurance auto la moins chère ?"],
"error": null
}
],
"recommendation": {
"action": "retire_serp_shifted",
"summary": "La SERP est devenue une page de comparateurs ; un guide n'y a plus sa place.",
"changes": [
"Rediriger la page vers le comparateur interne",
"Reprendre les deux sections utiles dans la page devis"
],
"rationale": "Les six premiers résultats sont des comparateurs transactionnels …",
"confidence": "medium"
}
}
],
"incidents": [
{
"url": "https://example.fr/ancienne-offre",
"market": "example.fr [fr-fr]",
"status": "cliff",
"reason": "the year-on-year ratio collapsed in bucket 4 after holding; a fall like this is a deindexation, a penalty or an unpublished page, not slow decay",
"clicks": { "current": 25.0, "baseline": 1200.0, "change": -0.979, "lost": 1175.0 },
"position": { "current": 18.4, "baseline": 4.1, "delta": 14.3 }
}
],
"errors": [],
"meta": {
"pages_read": 4820,
"rows_read": 61240,
"decaying_count": 37,
"reported_count": 37,
"cliff_count": 1,
"lost_count": 3,
"stable_count": 3910,
"growing_count": 402,
"insufficient_data_count": 467,
"clicks_lost": 9840.0,
"buckets_requested": 4,
"diagnosis_counts": { "serp_ctr_loss": 18, "position_loss": 12, "demand_decline": 5, "mixed": 2 },
"action_counts": { "refresh_content": 14, "retire_serp_shifted": 9, "internal_linking": 7, "accept_demand_decline": 5, "backlinks": 2 },
"max_pages": 50,
"serp_checks_performed": 20,
"max_serp_checks": 20,
"min_baseline_clicks": 100.0,
"min_decline": 0.2,
"min_trend_strength": 0.4,
"model": "gpt-5.6-luna",
"completed_at": "ISO-8601 timestamp"
}
},
"errors": [],
"message": "Content decay completed"
}Example
curl https://api.searchflow.app/api/v1/workflows/content-decay/runs/RUN_ID \
-H "Authorization: Bearer YOUR_API_KEY"/.well-known/oauth-protected-resource/mcpNo authProtected resource metadata
RFC 9728 metadata for the MCP endpoint: which authorization server issues tokens for it, and which scopes it understands. This is the document the 401 from /mcp points at in its WWW-Authenticate header, and it is the entry point to the whole connection flow. Served at the API origin, not the website. The same document is also served at /.well-known/oauth-protected-resource for clients that probe the bare origin. Not a workflow, and not billed.
Status codes
200Metadata document.
Response body
Protected resource metadata.
{
"resource": "https://api.searchflow.app/mcp",
"authorization_servers": ["https://api.searchflow.app"],
"scopes_supported": ["workflows"],
"bearer_methods_supported": ["header"],
"resource_name": "SearchFlow"
}Example
curl https://api.searchflow.app/.well-known/oauth-protected-resource/mcp/registerNo authRegister an MCP client
Dynamic client registration (RFC 7591). An MCP client cannot be pre-registered — it is software the user installed, and it learns this server exists when they paste the URL — so it registers itself here before the first connection. Unauthenticated by necessity; a registration on its own grants nothing until a signed-in user approves a connection. Not a workflow, and not billed.
Status codes
201Client registered.400Invalid client metadata, or a grant type this server does not issue.
Request body
Client metadata. redirect_uris is required.
{
"client_name": "string | null",
"redirect_uris": ["string (at least one)"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post | client_secret_basic | none"
}Response body
The registration as stored. client_secret is returned once and is not retrievable afterwards; a public client gets none.
{
"client_id": "uuid",
"client_id_issued_at": 1772668800,
"client_secret": "string | null",
"client_secret_expires_at": 0,
"redirect_uris": ["http://127.0.0.1:41234/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post"
}Example
curl -X POST https://api.searchflow.app/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My MCP client",
"redirect_uris": ["http://127.0.0.1:41234/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}'/tokenNo authExchange a code or refresh a token
Exchanges an authorization code for an access token and a refresh token, or rotates an existing pair. Form-encoded, per RFC 6749. A code is single-use: presenting it twice revokes everything the first exchange produced. Refresh rotates both halves, and replaying a rotated refresh token revokes the grant. Access tokens live one hour, refresh tokens thirty days. Not a workflow, and not billed.
Status codes
200Token issued.400invalid_grant (code used, expired, wrong code_verifier, or redirect_uri mismatch), invalid_scope, or unsupported_grant_type.401Client authentication failed.
Request body
Form-encoded. grant_type is authorization_code or refresh_token.
grant_type=authorization_code
code=THE_CODE
code_verifier=THE_PKCE_VERIFIER
client_id=YOUR_CLIENT_ID
client_secret=YOUR_CLIENT_SECRET # omitted by a public client
redirect_uri=http://127.0.0.1:41234/callbackResponse body
The token pair. Present access_token as a bearer token to /mcp.
{
"access_token": "sf_mcp_…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "workflows",
"refresh_token": "sf_mcp_…"
}Example
curl -X POST https://api.searchflow.app/token \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d code_verifier=THE_PKCE_VERIFIER \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d redirect_uri=http://127.0.0.1:41234/callback/revokeNo authDisconnect a client
Token revocation (RFC 7009). Revoking either half of a pair revokes both, and with them every rotation that came from the same consent. Returns 200 for a token that was already revoked or never existed, as the RFC requires. Not a workflow, and not billed.
Status codes
200Revoked, or nothing to revoke.400Malformed request.401Client authentication failed.
Request body
Form-encoded.
token=sf_mcp_…
token_type_hint=refresh_token # optional
client_id=YOUR_CLIENT_ID
client_secret=YOUR_CLIENT_SECRETResponse body
Empty body.
{}Example
curl -X POST https://api.searchflow.app/revoke \
-d token=sf_mcp_xxxxxxxx \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET/api/v1/health/providersAuth requiredProvider health
Reports which upstream providers this deployment holds credentials for, and whether DataForSEO answers. Not a workflow and not billed. Authenticated on purpose: which credentials a deployment carries is not public. reachable is null for a provider that is configured but not probed — only DataForSEO is called live, on an endpoint billed at zero.
Status codes
200Report produced.401Missing or invalid API key.
Response body
One entry per provider, in a fixed order.
{
"environment": "production",
"providers": [
{ "name": "semscraper", "configured": true, "reachable": null, "detail": null },
{ "name": "openai", "configured": true, "reachable": null, "detail": null },
{ "name": "exa", "configured": true, "reachable": null, "detail": null },
{ "name": "dataforseo", "configured": true, "reachable": true, "detail": "balance $412.55" }
]
}Example
curl https://api.searchflow.app/api/v1/health/providers \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/integrations/google/statusAuth requiredGoogle integration status
Whether this deployment can run the Google OAuth flow at all, and which scopes the consent screen will ask for. Not a workflow and not billed. Call it before offering the connect button: a deployment without the OAuth client credentials answers configured: false, and every other endpoint on this router would return 503.
Status codes
200Status returned.401Missing or invalid API key.
Response body
detail is null when the flow is available, and names the missing variables otherwise.
{
"configured": true,
"scopes": [
"openid",
"email",
"https://www.googleapis.com/auth/webmasters.readonly",
"https://www.googleapis.com/auth/analytics.readonly"
],
"detail": null
}Example
curl https://api.searchflow.app/api/v1/integrations/google/status \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/integrations/google/connectionsAuth requiredList Google connections
Every Google account connected by the caller, or by their organization. Not a workflow and not billed. A deployment without Supabase answers with an empty list rather than an error, because a page that cannot store a connection can still be rendered.
Status codes
200Connections returned.401Missing or invalid API key.
Response body
has_search_console and has_analytics report which scopes the user actually granted; status is active, revoked or error.
{
"configured": true,
"connections": [
{
"id": "6a1f0f3c-6d1e-4c2a-9f0b-6d4a2e5c8b71",
"google_account_email": "seo@example.com",
"scopes": [
"openid",
"email",
"https://www.googleapis.com/auth/webmasters.readonly",
"https://www.googleapis.com/auth/analytics.readonly"
],
"status": "active",
"has_search_console": true,
"has_analytics": true,
"organization_id": null,
"last_error": null,
"connected_at": "2026-08-19T09:14:22Z",
"last_used_at": "2026-09-01T07:41:03Z"
}
]
}Example
curl https://api.searchflow.app/api/v1/integrations/google/connections \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/integrations/google/connections/{connection_id}/propertiesAuth requiredList Google properties
Everything a connection can read — Search Console sites and GA4 properties — plus, when you send a site scope, which property would answer for which market. Not a workflow and not billed. POST rather than GET because the scope is the body: a multi-market site does not fit in a query string. Neither listing can fail the request; a project with the Analytics Admin API disabled still returns its Search Console sites, with the reason in analytics_error.
Status codes
200Listing returned.401Missing or invalid API key.404Connection not found or not accessible.422A body was sent carrying neither domain nor markets. Omit the body entirely instead of sending {}.503Google OAuth or Supabase not configured.
Request body
Optional as a whole, but not empty: send a domain, or an explicit markets list for a site serving several countries or languages. Omit the body entirely to get the raw listings with no suggestions — an object with neither field is rejected with 422. A scope that cannot be resolved costs the suggestions, not the listings.
{
"domain": "string (optional, e.g. example.be)",
"country": "string (optional, ISO country)",
"language": "string (optional, ISO language)",
"path_prefix": "string (optional, e.g. /fr)",
"markets": [
{
"domain": "string (required in a market)",
"country": "string (optional)",
"language": "string (optional)",
"path_prefix": "string (optional)"
}
]
}Response body
gsc_is_confident and ga4_is_confident say whether the match is safe to apply without asking the user.
{
"connection_id": "6a1f0f3c-6d1e-4c2a-9f0b-6d4a2e5c8b71",
"sites": [
{
"site_url": "sc-domain:example.be",
"permission_level": "siteOwner",
"is_domain_property": true,
"can_read_data": true
}
],
"properties": [
{
"property_id": "properties/412839571",
"display_name": "Example BE",
"account_name": "Example Group"
}
],
"search_console_error": null,
"analytics_error": null,
"suggestions": [
{
"market_key": "example.be|be|fr|/fr",
"domain": "example.be",
"country": "be",
"language": "fr",
"path_prefix": "/fr",
"label": "example.be/fr (be/fr)",
"gsc_site_url": "sc-domain:example.be",
"ga4_property_id": "properties/412839571",
"ga4_property_name": "Example BE",
"gsc_is_confident": true,
"ga4_is_confident": false
}
]
}Example
curl -X POST https://api.searchflow.app/api/v1/integrations/google/connections/CONNECTION_ID/properties \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"markets": [
{ "domain": "example.be", "country": "be", "language": "fr", "path_prefix": "/fr" },
{ "domain": "example.be", "country": "be", "language": "nl", "path_prefix": "/nl" }
]
}'/api/v1/integrations/google/connections/{connection_id}/bindingsAuth requiredRead Google property bindings
The property bound to each market of a connection. Not a workflow and not billed. market_key is the key a workflow looks up, so this is what tells you whether a market has a data source at all.
Status codes
200Bindings returned.401Missing or invalid API key.404Connection not found or not accessible.503Supabase not configured.
Response body
gsc_site_url or ga4_property_id may be null: a binding needs one of the two, not both.
{
"connection_id": "6a1f0f3c-6d1e-4c2a-9f0b-6d4a2e5c8b71",
"bindings": [
{
"market_key": "example.be|be|fr|/fr",
"domain": "example.be",
"country": "be",
"language": "fr",
"path_prefix": "/fr",
"gsc_site_url": "sc-domain:example.be",
"ga4_property_id": "properties/412839571",
"ga4_property_name": "Example BE"
}
]
}Example
curl https://api.searchflow.app/api/v1/integrations/google/connections/CONNECTION_ID/bindings \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/integrations/google/connections/{connection_id}/bindingsAuth requiredBind Google properties to markets
Binds one Search Console site and/or one GA4 property per market, and returns the full set afterwards. Not a workflow and not billed. Upserts: sending a market again replaces its binding. Every market is resolved through the same scope rules a workflow uses, so a market the rules refuse — a bare .be with no language, for one — is rejected here rather than producing a report on a market the site may not serve.
Status codes
200Bindings stored.401Missing or invalid API key.404Connection not found or not accessible.422A market cannot be resolved, or a binding names neither a Search Console site nor a GA4 property.503Supabase not configured.
Request body
1 to 20 bindings. Each one needs a Search Console site, a GA4 property, or both.
{
"bindings": [
{
"market": {
"domain": "string (required)",
"country": "string (optional)",
"language": "string (optional)",
"path_prefix": "string (optional)"
},
"gsc_site_url": "string (optional, max 512 chars)",
"ga4_property_id": "string (optional, max 128 chars)",
"ga4_property_name": "string (optional, max 256 chars)"
}
]
}Response body
The same body as the GET: every binding of the connection, resolved.
{
"connection_id": "6a1f0f3c-6d1e-4c2a-9f0b-6d4a2e5c8b71",
"bindings": [
{
"market_key": "example.be|be|fr|/fr",
"domain": "example.be",
"country": "be",
"language": "fr",
"path_prefix": "/fr",
"gsc_site_url": "sc-domain:example.be",
"ga4_property_id": "properties/412839571",
"ga4_property_name": "Example BE"
}
]
}Example
curl -X POST https://api.searchflow.app/api/v1/integrations/google/connections/CONNECTION_ID/bindings \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bindings": [
{
"market": { "domain": "example.be", "country": "be", "language": "fr", "path_prefix": "/fr" },
"gsc_site_url": "sc-domain:example.be",
"ga4_property_id": "properties/412839571"
}
]
}'/api/v1/integrations/google/connections/{connection_id}/checkAuth requiredCheck a Google connection
Calls both Google APIs for real and reports what happened. Not a workflow and not billed. A stored connection can look healthy for weeks after the user revoked it in their Google account; only a call finds that out. A connection that answers on either API is marked active again.
Status codes
200Probe finished — read the flags.401Missing or invalid API key.404Connection not found or not accessible.503Google OAuth or Supabase not configured.
Response body
200 even when a probe fails: the failure is the answer, and it is reported per API rather than as an HTTP error.
{
"connection_id": "6a1f0f3c-6d1e-4c2a-9f0b-6d4a2e5c8b71",
"search_console_ok": true,
"search_console_detail": "3 properties",
"analytics_ok": false,
"analytics_detail": "The Google Analytics Admin API is not enabled on this Cloud project"
}Example
curl -X POST https://api.searchflow.app/api/v1/integrations/google/connections/CONNECTION_ID/check \
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/integrations/google/connections/{connection_id}/disconnectAuth requiredDisconnect a Google account
Revokes the grant at Google, then deletes the connection and its bindings. Not a workflow and not billed. The deletion happens whatever Google answers — a grant it has already forgotten returns an error, and refusing to delete for that reason would leave a dead connection the user cannot replace. revoked_at_google false means the row is gone but the user may still want to clear the app in their Google account.
Status codes
200Connection deleted.401Missing or invalid API key.404Connection not found or not accessible.503Supabase not configured.
Response body
The id that was removed, and whether Google accepted the revocation.
{
"connection_id": "6a1f0f3c-6d1e-4c2a-9f0b-6d4a2e5c8b71",
"revoked_at_google": true
-H "Authorization: Bearer YOUR_API_KEY"/api/v1/mcp/oauth/requests/{request_id}Auth requiredRead a pending MCP connection request
Everything the consent screen shows: the name the client registered for itself, the host the token will actually be delivered to, the scopes asked for, and the credit balance the connection would be able to spend. Not a workflow and not billed. A browser session is required — an API key is refused with 403, because a key that could approve its own connection would mint a grant that outlives it and that revoking the key would not touch.
Status codes
200The request is pending and can still be answered.401No session.403Presented with an API key instead of a browser session.404No such connection request.409Already answered. Start again from the MCP client.410Expired. Start again from the MCP client.
Response body
credit_balance is null when the balance could not be read; the screen still renders, because the decision does not depend on it.
{
"request_id": "1b8d4f6a-5c2e-4a91-9d3f-0e7c2b6a4185",
"client_name": "Claude Desktop",
"client_uri": "https://claude.ai",
"redirect_host": "claude.ai",
"scopes": ["workflows"],
"expires_at": "2026-09-05T10:14:00Z",
"credit_balance": 412.5
}Example
curl https://api.searchflow.app/api/v1/mcp/oauth/requests/REQUEST_ID -H "Authorization: Bearer YOUR_SESSION_JWT"/api/v1/mcp/oauth/requests/{request_id}/decisionAuth requiredApprove or refuse an MCP connection request
Answers the consent screen and returns the URL to send the browser back to. Not a workflow and not billed — but approving mints a token that can start runs, and those cost credits. The request is claimed in a single statement before anything is issued, so a double click produces one authorization code, not two.
Status codes
200Answered. Follow redirect_url.401No session.403Presented with an API key instead of a browser session.404No such connection request.409Already answered by another tab or an earlier click.410Expired. Start again from the MCP client.
Request body
One field.
{
"approve": "boolean (required, false sends the client an access_denied error)"
}Response body
redirect_url carries the authorization code on approval, and an access_denied error on refusal. Either way the browser goes back to the client.
{
"redirect_url": "https://claude.ai/api/mcp/auth_callback?code=...&state=...",
"approved": true
}Example
curl -X POST https://api.searchflow.app/api/v1/mcp/oauth/requests/REQUEST_ID/decision -H "Authorization: Bearer YOUR_SESSION_JWT" -H "Content-Type: application/json" -d '{"approve": true}'