---
title: "Storing & Ingesting FullMention Data: Developer's Guide"
description: "A comprehensive technical guide for developers on how to securely fetch, paginate, and store FullMention AI Share of Voice tracking data."
pubDate: "2026-06-03T00:00:00.000Z"
author: "Søren Riisager"
image: "/ai-share-of-voice.png"
---

**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 /runs`, you will receive a run ID, but the results are not ready immediately.

You have two options for tracking run completion:

### Option A: Webhooks (Recommended)
Configure a webhook endpoint in your developer portal. When a batch job completes, FullMention sends a payload to your endpoint:

```json
{
  "event": "run.completed",
  "runId": "run_98765",
  "tags": ["client:acme", "market:dk"],
  "completedAt": "2026-06-03T21:46:00Z"
}
```

### 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 /runs/{runId}`. Once the status field changes from `pending` or `processing` to `completed`, trigger your local data ingestion handler.

---

## 3. Handling Cursor-Based Pagination

FullMention's results API returns data in paginated lists to ensure fast response times. The default limit is 100 items per page. 

To fetch the entire dataset for a run:
1. Start by calling `GET /results/latest?limit=100&tags=client:acme`.
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 a run fits into a single page. If you skip pagination, your analytics will be incomplete and show incorrect Share of Voice scores.

---

## 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. To draw charts showing Share of Voice over time, you must persist the incoming data locally.

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

```sql
-- 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,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Track the status of every triggered run
CREATE TABLE scheduled_runs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  run_id TEXT UNIQUE NOT NULL,
  label TEXT,
  tags TEXT[] NOT NULL,
  status TEXT NOT NULL,
  error TEXT,
  triggered_at TIMESTAMPTZ DEFAULT now(),
  snapshotted_at TIMESTAMPTZ,
  completed_at TIMESTAMPTZ
);

-- Aggregate Share-of-Voice scores per engine for your brand and competitors
CREATE TABLE run_snapshots (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  run_id TEXT REFERENCES scheduled_runs(run_id) ON DELETE CASCADE,
  engine TEXT NOT NULL, -- 'openai' or 'gemini'
  brand_name TEXT NOT NULL,
  mention_count INTEGER NOT NULL DEFAULT 0,
  total_results INTEGER NOT NULL DEFAULT 0,
  avg_rank NUMERIC(5,2),
  score NUMERIC(10,4) NOT NULL DEFAULT 0.0,
  completed_at TIMESTAMPTZ
);

-- Track cited domains influencing the AI SERPs
CREATE TABLE run_host_ranks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  run_id TEXT REFERENCES scheduled_runs(run_id) ON DELETE CASCADE,
  host TEXT NOT NULL,
  mention_count INTEGER NOT NULL DEFAULT 0,
  total_results INTEGER NOT NULL DEFAULT 0,
  avg_rank NUMERIC(5,2),
  score NUMERIC(10,4) NOT NULL DEFAULT 0.0,
  completed_at TIMESTAMPTZ
);
```

---

## 5. Reciprocal Rank Scoring & Ingestion Code

FullMention v3 schema returns flat, root-level arrays of `brandRankings`, `websiteRankings`, and `productRankings` directly inside each keyword 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:

```typescript
import axios from 'axios';

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

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

interface Result {
  id: string;
  keyword: string;
  engine: string;
  brandRankings?: Ranking[];
  websiteRankings?: WebsiteRanking[];
}

interface StorageMetrics {
  mentionCount: number;
  totalResults: number;
  rankSum: number;
  score: number;
}

// Calculate scores server-side
function calculateSoV(
  results: Result[],
  targetBrand: string,
  competitors: string[]
): Record<string, StorageMetrics> {
  const allBrands = [targetBrand, ...competitors];
  const metrics: Record<string, StorageMetrics> = {};

  for (const brand of allBrands) {
    metrics[brand] = { mentionCount: 0, totalResults: results.length, rankSum: 0, score: 0 };
  }

  for (const res of results) {
    const rankings = res.brandRankings || [];
    
    for (const ranking of rankings) {
      // Clean name for case-insensitive matching
      const cleanedName = ranking.name.toLowerCase().trim();
      
      for (const brand of allBrands) {
        if (cleanedName.includes(brand.toLowerCase().trim())) {
          const m = metrics[brand];
          m.mentionCount += 1;
          m.rankSum += ranking.position;
          m.score += 1 / ranking.position;
        }
      }
    }
  }

  return metrics;
}

// Ingestion Orchestrator using meta.nextCursor
async function ingestRunData(runId: string, targetBrand: string, competitors: string[]) {
  const apiKey = process.env.FULLMENTION_API_KEY;
  let cursor: string | null = null;
  const allResults: Result[] = [];

  do {
    const url = `https://api.fullmention.com/v3/results`;
    const response = await axios.get(url, {
      headers: { 'Authorization': `Bearer ${apiKey}` },
      params: {
        runId: runId,
        limit: 100,
        cursor: cursor || undefined
      }
    });

    const data = response.data;
    allResults.push(...data.data);
    
    // Read cursor for next page loop
    cursor = data.meta?.nextCursor || null;
  } while (cursor !== null);

  // Split results by engine for local metrics tracking
  const openaiResults = allResults.filter(r => r.engine === 'openai');
  const geminiResults = allResults.filter(r => r.engine === 'gemini');

  const openaiMetrics = calculateSoV(openaiResults, targetBrand, competitors);
  const geminiMetrics = calculateSoV(geminiResults, targetBrand, competitors);

  // Write metrics to SQL database here...
  console.log('Ingestion completed. Metrics calculated successfully.');
}
```

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