Skip to content

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" }
GET/api/v1/healthNo auth

Health 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
POST/api/v1/workflows/keyword-clustering/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/keyword-clustering/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/indexation-check/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/indexation-check/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/ecommerce-category-content/runsAuth required

Create 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"
  }'
GET/api/v1/workflows/ecommerce-category-content/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/product-content/runsAuth required

Create 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": {} }
  }'
GET/api/v1/workflows/product-content/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/keyword-research/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/keyword-research/runs/{run_id}Auth required

Get 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"
POST/api/v1/keyword-research/site-descriptionAuth required

Describe 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" }'
POST/api/v1/keyword-research/brand-variantsAuth required

Suggest 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" }'
POST/api/v1/workflows/rank-or-create/runsAuth required

Create 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" }
  }'
GET/api/v1/workflows/rank-or-create/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/coverage-gap-analysis/runsAuth required

Create 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"]
    }
  }'
GET/api/v1/workflows/coverage-gap-analysis/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/local-seo-page/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/local-seo-page/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/informational-content/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/informational-content/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/technical-crawl/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/technical-crawl/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/discover-topics/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/discover-topics/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/discover-article/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/discover-article/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/cannibalization-analysis/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/cannibalization-analysis/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/internal-linking-analysis/runsAuth required

Create 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"]
    }
  }'
GET/api/v1/workflows/internal-linking-analysis/runs/{run_id}Auth required

Get 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"
POST/api/v1/workflows/content-decay/runsAuth required

Create 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 }
  }'
GET/api/v1/workflows/content-decay/runs/{run_id}Auth required

Get 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"
GET/.well-known/oauth-protected-resource/mcpNo auth

Protected 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
GET/.well-known/oauth-authorization-serverNo auth

Authorization server metadata

RFC 8414 metadata for the authorization server SearchFlow hosts for its MCP endpoint. Every endpoint named here is served from this origin. PKCE with S256 is the only supported challenge method, and the issuer is compared as an exact string, so it carries no trailing slash. Not a workflow, and not billed.

Status codes

  • 200Metadata document.

Response body

Authorization server metadata.

{
  "issuer": "https://api.searchflow.app",
  "authorization_endpoint": "https://api.searchflow.app/authorize",
  "token_endpoint": "https://api.searchflow.app/token",
  "registration_endpoint": "https://api.searchflow.app/register",
  "revocation_endpoint": "https://api.searchflow.app/revoke",
  "scopes_supported": ["workflows"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"]
}

Example

curl https://api.searchflow.app/.well-known/oauth-authorization-server
POST/registerNo auth

Register 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"]
  }'
GET/authorizeNo auth

Start a connection

The authorization endpoint. PKCE is mandatory: code_challenge is required and S256 is the only accepted method. Nothing is issued here — the response is a 302 to the SearchFlow consent screen, where the user signs in and sees what the client will be able to do, including that it will be able to spend credits. Approving redirects the browser back to redirect_uri with a single-use code valid for five minutes. Not a workflow, and not billed — what the resulting token can spend is named on the consent screen.

Status codes

  • 302Redirect to the consent screen, or back to redirect_uri with an error.
  • 400Unknown client_id, or a redirect_uri that does not match the registration.

Response body

No body. The Location header carries the next step.

Location: https://searchflow.app/app/mcp/authorize/{request_id}

Example

curl -i "https://api.searchflow.app/authorize\
?client_id=YOUR_CLIENT_ID\
&response_type=code\
&redirect_uri=http://127.0.0.1:41234/callback\
&code_challenge=BASE64URL_SHA256_OF_VERIFIER\
&code_challenge_method=S256\
&state=RANDOM"
POST/tokenNo auth

Exchange 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/callback

Response 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
POST/revokeNo auth

Disconnect 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_SECRET

Response 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
GET/api/v1/health/providersAuth required

Provider 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"
GET/api/v1/integrations/google/statusAuth required

Google 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"
GET/api/v1/integrations/google/connectionsAuth required

List 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"
POST/api/v1/integrations/google/startAuth required

Start Google authorization

Mints a single-use state and returns the Google consent URL to send the browser to. Not a workflow and not billed. The state expires and can be consumed once; keep it, because the callback is refused without it.

Status codes

  • 200Authorization URL minted.
  • 401Missing or invalid API key.
  • 422redirect_path is not a path on this site (an absolute URL, or a protocol-relative //host).
  • 503Google OAuth or Supabase not configured on this deployment.

Request body

redirect_path is where the dashboard sends the user once the connection is stored. It must be a path on the site, never an absolute URL — an open redirect here would bounce a freshly authorized user off-site.

{
  "redirect_path": "string (optional, a path on this site, max 512 chars)"
}

Response body

Send the browser to authorization_url; hand state back to the callback endpoint unchanged.

{
  "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=…&state=…",
  "state": "5f3c9a1e8b7d4c2f9a0e6b3d1c8f7a45"
}

Example

curl -X POST https://api.searchflow.app/api/v1/integrations/google/start \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "redirect_path": "/app/settings/integrations" }'
POST/api/v1/integrations/google/callbackAuth required

Complete Google authorization

Exchanges the authorization code for tokens and stores the connection. Not a workflow and not billed. Google redirects to the Next.js app, which forwards code and state here — the client secret never reaches the process that renders pages. Two checks run: the state must be unexpired and unused, and it must belong to the account signed in right now.

Status codes

  • 200Connection stored.
  • 401Missing or invalid API key.
  • 400State expired or already used, code rejected by Google, or Google did not return an account (the OAuth client must request the openid and email scopes).
  • 403The state was minted by another account.
  • 503Google OAuth or Supabase not configured, or the token cipher is unavailable.

Request body

Both values come straight from Google's redirect, unmodified.

{
  "code": "string (required, the authorization code, max 2048 chars)",
  "state": "string (required, the state returned by /start, max 512 chars)"
}

Response body

redirect_path echoes what /start was given, so the dashboard knows where to return the user.

{
  "connection": {
    "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-09-04T08:22:10Z",
    "last_used_at": null
  },
  "redirect_path": "/app/settings/integrations"
}

Example

curl -X POST https://api.searchflow.app/api/v1/integrations/google/callback \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "4/0AVMBsJi…",
    "state": "5f3c9a1e8b7d4c2f9a0e6b3d1c8f7a45"
  }'
POST/api/v1/integrations/google/connections/{connection_id}/propertiesAuth required

List 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" }
    ]
  }'
GET/api/v1/integrations/google/connections/{connection_id}/bindingsAuth required

Read 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"
POST/api/v1/integrations/google/connections/{connection_id}/bindingsAuth required

Bind 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"
      }
    ]
  }'
POST/api/v1/integrations/google/connections/{connection_id}/checkAuth required

Check 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"
POST/api/v1/integrations/google/connections/{connection_id}/disconnectAuth required

Disconnect 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"