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.
Base URL: https://api.searchflow.app · API prefix: /api/v1
Last updated: 12 August 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.
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"