> ## Documentation Index
> Fetch the complete documentation index at: https://dev.ranked.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Building an Agency Dashboard

> How to use the API and webhooks to build a custom client-facing dashboard

## Overview

Agencies can build custom dashboards on top of Ranked AI's data using the REST API for data retrieval and webhooks for real-time updates. This guide walks through the recommended architecture.

## Architecture

Your dashboard pulls data from the API and caches it locally. Webhooks push updates so you don't need to poll.

<Steps>
  <Step title="API pulls data into your database">
    Your server calls the Ranked AI API to fetch keywords, audits, backlinks, and other data, then stores it in your database.
  </Step>

  <Step title="Webhooks push real-time updates">
    When keyword positions update or content changes, Ranked AI sends a webhook to your server with the event details.
  </Step>

  <Step title="Your dashboard reads from the cache">
    Your frontend reads from your local database instead of calling the API on every page load. Fast and always available.
  </Step>
</Steps>

## Step 1: Discover projects

List all SEO projects for the authenticated user:

```javascript theme={null}
const response = await fetch('https://app.ranked.ai/api/v1/projects', {
  headers: { 'Authorization': `Bearer ${apiKey}` }
});
const { data: projects } = await response.json();

// Store project IDs for subsequent calls
projects.forEach(project => {
  db.upsert('projects', {
    ranked_id: project.id,
    name: project.name,
    website: project.websiteUrl,
    status: project.status,
  });
});
```

## Step 2: Initial data sync

Pull the full dataset for each project:

```javascript theme={null}
async function syncProject(projectId) {
  const headers = { 'Authorization': `Bearer ${apiKey}` };
  const base = `https://app.ranked.ai/api/v1/projects/${projectId}`;

  // Fetch all data in parallel
  const [keywords, audits, backlinks, prompts, content] = await Promise.all([
    fetch(`${base}/rankings/keywords?limit=1000`, { headers }).then(r => r.json()),
    fetch(`${base}/audits/latest`, { headers }).then(r => r.json()),
    fetch(`${base}/backlinks/summary`, { headers }).then(r => r.json()),
    fetch(`${base}/prompts?limit=200`, { headers }).then(r => r.json()),
    fetch(`${base}/content?limit=100`, { headers }).then(r => r.json()),
  ]);

  // Store in your database
  await db.upsertKeywords(projectId, keywords.data);
  await db.upsertAudit(projectId, audits.data);
  await db.upsertBacklinks(projectId, backlinks.data);
  await db.upsertPrompts(projectId, prompts.data);
  await db.upsertContent(projectId, content.data);
}
```

## Step 3: Set up webhooks for real-time updates

Instead of polling, subscribe to events:

```javascript theme={null}
// Create webhook for each project
for (const project of projects) {
  await fetch('https://app.ranked.ai/api/v1/webhooks', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${writeApiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: `Dashboard - ${project.name}`,
      url: 'https://your-dashboard.com/webhooks/ranked',
      project_id: project.id,
      events: [
        'keywords.updated',
        'content.status_changed',
        'content.created',
        'audit.completed',
        'prompts.updated',
      ],
    }),
  });
}
```

## Step 4: Handle webhook events

```javascript theme={null}
app.post('/webhooks/ranked', async (req, res) => {
  // Verify signature first (see Webhook Security docs)
  if (!verifySignature(req)) return res.status(401).send();

  const { event, project_id, data } = req.body;

  switch (event) {
    case 'keywords.updated':
      // Re-sync keyword positions from the API
      const keywords = await fetch(
        `https://app.ranked.ai/api/v1/projects/${project_id}/rankings/keywords?limit=1000`,
        { headers: { 'Authorization': `Bearer ${apiKey}` } }
      ).then(r => r.json());
      await db.upsertKeywords(project_id, keywords.data);
      break;

    case 'audit.completed':
      // Fetch the latest audit results
      const audit = await fetch(
        `https://app.ranked.ai/api/v1/projects/${project_id}/audits/latest`,
        { headers: { 'Authorization': `Bearer ${apiKey}` } }
      ).then(r => r.json());
      await db.upsertAudit(project_id, audit.data);
      break;

    case 'content.status_changed':
      // Update content status in your DB
      await db.updateContentStatus(data.content_id, data.new_status);
      break;

    case 'prompts.updated':
      // Refresh AI visibility data for this prompt
      const prompt = await fetch(
        `https://app.ranked.ai/api/v1/projects/${project_id}/prompts/${data.prompt_id}`,
        { headers: { 'Authorization': `Bearer ${apiKey}` } }
      ).then(r => r.json());
      await db.upsertPrompt(project_id, prompt.data);
      break;
  }

  res.status(200).send('OK');
});
```

## Recommended sync strategy

| Data              | Strategy                                       | Frequency                    |
| ----------------- | ---------------------------------------------- | ---------------------------- |
| Keyword positions | Webhook `keywords.updated` triggers API pull   | Daily (after scan completes) |
| AI visibility     | Webhook `prompts.updated` triggers API pull    | Monthly (after analysis)     |
| Audit results     | Webhook `audit.completed` triggers API pull    | Monthly (after scan)         |
| Content status    | Webhook `content.status_changed` for real-time | Real-time                    |
| Backlinks         | Scheduled API pull (no webhook yet)            | Weekly cron job              |
| Reports           | On-demand API call                             | As needed                    |

## Tips

* Use **Read Only** API keys for data fetching and a separate **Read + Write** key for webhook management
* Cache API responses in your database rather than calling the API on every page load
* The `keywords.updated` webhook fires once per daily scan, not per keyword -- use it as a signal to re-sync
* Implement retry logic for your API calls with exponential backoff
