FullMention
Get API Access
June 3, 2026

Storing & Ingesting FullMention Data: Developer's Guide

Bottom Line Up Front: FullMention is optimized to monitor, parse, and analyze your brand footprint across ChatGPT and Gemini in large asynchronous batches. Because Answer Engine Optimization (AEO) tracks historical trends, and because the FullMention API enforces quota limits, client applications must never call the API directly from the browser. Instead, you should ingest results using a secure backend pipeline, handle cursor-based pagination, and persistently store snapshots in a local database.

Here is the developer blueprint to set up a robust, cost-effective data ingestion and storage engine.


1. Architectural Guardrails: Frontend Isolation

Before writing any database queries, establish a secure backend proxy pattern. Calling api.fullmention.com directly from a client-side frontend app (like a React or Vue SPA) leads to two main problems:

  • API Key Exposure: Your FULLMENTION_API_KEY is a secret credential. Exposing it in client bundles allows anyone to steal your credits.
  • CORS Blockers: The FullMention API has strict origin controls. All API requests must be initiated by your own server or background workers.
┌────────────────┐        ┌────────────────┐        ┌────────────────────┐
│                │  HTTP  │  Your Backend  │  HTTP  │    FullMention     │
│  Client SPA    │───────>│  Service/Proxy │───────>│     API Engine     │
│ (Dashboard UI) │        │ (Holds Secret) │        │ (Batch Processor)  │
└────────────────┘        └────────────────┘        └────────────────────┘

Your server acts as the secure intermediary, holding the API key in its environment variables and serving sanitized, cached data to your users.


2. Ingestion Workflows: Async Runs and Webhooks

FullMention processes keyword tracking in batches using AI engines. This means runs are asynchronous. When you trigger a run using POST /v2/runs, you will receive a run ID, but the results are not ready immediately.

You have two options for tracking run completion:

Configure a webhook endpoint in your developer portal or supply a webhookUrl directly in the run payload. When a batch job completes, FullMention sends a payload to your endpoint:

{
  "event": "run.completed",
  "timestamp": "2026-06-03T21:46:00Z",
  "data": {
    "id": "run_98765",
    "status": "success",
    "results": [
      {
        "id": "res_kw1_openai_mini",
        "keyword": "seo tool",
        "engine": "openai-mini",
        "country": "Denmark",
        "language": "Danish",
        "brandRankings": [
          { "position": 1, "name": "Acme Inc" }
        ],
        "websiteRankings": [
          { "position": 1, "domain": "acme.com" }
        ]
      }
    ]
  }
}

Option B: Status Polling

If you cannot expose a public webhook endpoint, run a background cron job that periodically checks the status of your active runs via GET /v2/runs/{runId}. Once the status field changes from queued or processing to success, trigger your local data ingestion handler.


3. Handling Cursor-Based Pagination for Citations

When a run is completed, GET /v2/runs/{runId} returns the complete results inline (no pagination required for keywords and rankings). However, if web search fanout was enabled, the crawl citation sources for each result are retrieved via a paginated endpoint:

GET /v2/runs/{runId}/results/{resultId}/fanout-sources

To fetch the full list of citation sources for a result:

  1. Start by calling GET /v2/runs/{runId}/results/{resultId}/fanout-sources?limit=100.
  2. Extract the meta.nextCursor token from the JSON response.
  3. If nextCursor is present and not null, perform a follow-up request appending &cursor=VALUE.
  4. Loop recursively until nextCursor returns null.

Never assume all citation sources fit into a single page. If you skip pagination, your analytics will be incomplete and show incorrect domain citation coverage.


4. The Local Database Schema (SQL)

FullMention does not store historical timeline results indefinitely; it is designed to hold the latest snapshot of your visibility. Because FullMention deletes run data and results after exactly 24 hours, you must persist the incoming data locally to draw historical Share of Voice charts.

Here is the production-ready SQL schema (PostgreSQL) optimized for relational metrics:

-- Track configuration and target domains
CREATE TABLE settings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  brand_name TEXT NOT NULL UNIQUE,
  competitors TEXT[] NOT NULL,
  default_engines TEXT[] NOT NULL,
  default_country TEXT NOT NULL,
  default_language TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Track the status and metadata of triggered runs
CREATE TABLE runs (
  run_id TEXT PRIMARY KEY,
  status TEXT NOT NULL,
  total_requests INTEGER NOT NULL DEFAULT 0,
  completed_requests INTEGER NOT NULL DEFAULT 0,
  progress_percentage INTEGER NOT NULL DEFAULT 0,
  estimated_credits INTEGER NOT NULL DEFAULT 0,
  webhook_url TEXT,
  expires_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  completed_at TIMESTAMPTZ
);

-- Store individual keyword results
CREATE TABLE results (
  id TEXT PRIMARY KEY,
  run_id TEXT REFERENCES runs(run_id) ON DELETE CASCADE,
  keyword TEXT NOT NULL,
  engine TEXT NOT NULL, -- 'openai', 'openai-mini', or 'gemini'
  country TEXT NOT NULL,
  language TEXT NOT NULL,
  location TEXT,
  description TEXT NOT NULL,
  category_suggestions JSONB NOT NULL,
  brand_rankings JSONB NOT NULL,
  website_rankings JSONB NOT NULL,
  product_rankings JSONB NOT NULL,
  metrics JSONB NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL
);

-- Store crawl citation sources for results (if fanout was enabled)
CREATE TABLE fanout_sources (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  result_id TEXT REFERENCES results(id) ON DELETE CASCADE,
  domain TEXT NOT NULL,
  url TEXT NOT NULL,
  title TEXT NOT NULL,
  snippet TEXT NOT NULL,
  crawled_at TIMESTAMPTZ NOT NULL
);

5. Reciprocal Rank Scoring & Ingestion Code

FullMention v2 returns flat, root-level arrays of brandRankings, websiteRankings, and productRankings directly inside each result object.

To calculate the overall Share of Voice (SoV) for your brand, compute a weighted reciprocal rank score: $$\text{Score} = \sum \frac{1}{\text{position}}$$

For example, a brand recommended at position 1 gets a score of $1.0$, while position 2 gets $0.5$, and position 3 gets $0.33$.

Here is a TypeScript reference implementation for your background worker that polls the status of a run, retrieves its results, paginates through the citation sources, and stores them locally:

import axios from 'axios';

interface Ranking {
  position: number;
  name: string;
}

interface WebsiteRanking {
  position: number;
  domain: string;
}

interface Result {
  id: string;
  keyword: string;
  engine: string;
  country: string;
  language: string;
  location?: string;
  description: string;
  categorySuggestions: string[];
  brandRankings: Ranking[];
  websiteRankings: WebsiteRanking[];
  productRankings: any[];
  metrics: {
    brandCount: number;
    websiteCount: number;
    productCount: number;
    fanoutQueryCount: number;
    fanoutSourceCount: number;
  };
  updatedAt: string;
}

interface RunResponse {
  id: string;
  status: 'queued' | 'processing' | 'success' | 'failed';
  results?: Result[];
}

interface FanoutSource {
  domain: string;
  url: string;
  title: string;
  snippet: string;
  crawledAt: string;
}

// Ingestion Orchestrator
async function ingestRun(runId: string) {
  const apiKey = process.env.FULLMENTION_API_KEY;
  const baseUrl = 'https://api.fullmention.com/v2';

  // 1. Fetch Run Results (Poll until complete if not using Webhooks)
  let runData: RunResponse;
  while (true) {
    const response = await axios.get<RunResponse>(`${baseUrl}/runs/${runId}`, {
      headers: { 'Authorization': `Bearer ${apiKey}` }
    });
    runData = response.data;
    if (runData.status === 'success' || runData.status === 'failed') {
      break;
    }
    // Wait 30 seconds before polling again
    await new Promise(resolve => setTimeout(resolve, 30000));
  }

  if (runData.status === 'failed' || !runData.results) {
    throw new Error('FullMention run execution failed or returned no results.');
  }

  // Save the run and results to database...
  for (const res of runData.results) {
    // Save Result: INSERT INTO results ...
    
    // 2. Fetch Paginated Fanout Sources if search was enabled and sources were found
    if (res.metrics.fanoutSourceCount > 0) {
      let cursor: string | null = null;
      do {
        const sourceResponse = await axios.get<{ data: FanoutSource[]; meta: { nextCursor?: string } }>(
          `${baseUrl}/runs/${runId}/results/${res.id}/fanout-sources`,
          {
            headers: { 'Authorization': `Bearer ${apiKey}` },
            params: {
              limit: 100,
              cursor: cursor || undefined
            }
          }
        );

        const sources = sourceResponse.data.data;
        for (const src of sources) {
          // Save Fanout Source: INSERT INTO fanout_sources ...
        }

        cursor = sourceResponse.data.meta?.nextCursor || null;
      } while (cursor !== null);
    }
  }

  console.log('Ingestion completed and cached locally successfully.');
}

By storing these pre-calculated metrics and full results on every run, you can render lightning-fast timeline charts and monitor trends without overloading the API or risking credit exhaustion.

Ready to dominate the AI search landscape?

Stop tracking simple keywords. Start tracking true Market Dominance with our 30x Fan-Out Engine. All plans include full ChatGPT & Gemini tracking.

Starter

€19/mo

1,400 Credits

Full API Access

~46 credits / day avg

Subscribe to Starter
Popular

Pro

€59/mo

4,500 Credits

Full API Access

~150 credits / day avg

Subscribe to Pro

Growth

€119/mo

9,500 Credits

Full API Access

~316 credits / day avg

Subscribe to Growth