Specification

Copper Data API

A single endpoint format for exporting any structured data from your site to Copper Analytics. Roadmaps, blog stats, SEO metrics, and more — all through one authenticated GET /api/copper endpoint.

Overview

The Copper Data API is a convention for sites to export structured data to Copper Analytics through a single authenticated endpoint. Instead of configuring a separate URL for each data type, your site serves one GET /api/copper endpoint that returns an envelope containing one or more modules.

Each module is a named data type with its own schema. Copper Analytics currently supports:

roadmaps

Product roadmap items with status, phase, priority, and task progress. Follows the Copper Roadmap Format.

blog

Blog and content metrics — article count, categories, recent posts, and publishing activity. Useful for tracking SEO and content output across sites.

Sites include whichever modules they have data for. Copper Analytics ignores unknown modules gracefully, so you can add new ones without breaking existing integrations.

Why one endpoint

A single endpoint simplifies everything on both sides:

ConcernMultiple endpointsSingle endpoint
ConfigurationURL + token per data typeOne URL + one token for everything
Adding a new data typeNew endpoint + new config in Copper AnalyticsAdd a key to the response — no config change
Network requestsN requests per sync (one per type)1 request per sync
Token managementPotentially different tokens per endpointOne token, one rotation

Note

If your site already exposes /api/roadmap from the Copper Roadmap Format spec, that still works. The Data API is the recommended path forward — see Migrating from /api/roadmap below.

Envelope format

The response is a JSON object with a standard envelope and a modules object containing the data types your site exports:

TypeScript types
interface CopperDataResponse {
  site: string;          // Your site's domain (e.g. "myapp.com")
  fetchedAt: string;     // ISO timestamp of when this response was generated
  version: 1;            // Schema version (always 1 for now)
  modules: {
    roadmaps?: RoadmapsModule;
    blog?: BlogModule;
    // Future modules will be added here
  };
}

Every key in modules is optional. Include only the data types your site has. Copper Analytics will read whichever modules are present and ignore the rest.

GET/api/copper

Returns the data envelope with all modules your site exports. Requires authentication (see below).

Requirements

  • Must return Content-Type: application/json
  • Must return HTTP 200 even if some modules have no data (return empty arrays/objects)
  • Must include site, fetchedAt, and version in every response
  • Should respond within 10 seconds (Copper Analytics applies a timeout per site)
  • version must be 1 (future versions will be documented here)

Authentication

Copper Analytics authenticates to your endpoint using a read-only token that you create and paste into the site integration settings. Three auth modes are supported:

ModeHow Copper Analytics sends the credential
Bearer tokenAuthorization: Bearer <token>
Custom headerx-api-key: <token> (header name is configurable)
Query parameter?api_key=<token> (param name is configurable)

See the Roadmap Sync Setup > Auth modes page for details on configuring each mode in the dashboard.

Modules

Each module is a key inside modules with its own schema. Include only the modules your site has data for.

roadmaps

roadmaps module

Product roadmap items parsed from Markdown files with YAML frontmatter. This is the same data format as the Copper Roadmap Format — the schema is identical.
RoadmapsModule schema
interface RoadmapsModule {
  items: RoadmapItem[];
}

interface RoadmapItem {
  slug: string;           // Filename without .md (e.g. "2026-03-10-dark-mode")
  title: string;          // From frontmatter
  status: 'proposed' | 'in-progress' | 'completed' | 'paused';
  date: string;           // ISO date (YYYY-MM-DD)
  priority?: 'low' | 'medium' | 'high' | 'critical';
  phase?: number;
  tags?: string[];
  description: string;    // Full markdown body
  summary: string;        // First 500 chars of description
  tasks?: {
    total: number;
    completed: number;
    remaining: number;
    percent: number;      // 0-100
  };
}

Roadmap items are typically read from ./roadmaps/*.md files. See the Copper Roadmap Format for the full Markdown and frontmatter specification.

blog

blog module

Content and SEO metrics — how many articles your site publishes, which categories they fall into, and when the latest content was published. Copper Analytics displays these in the site dashboard for tracking content output across multiple sites.
BlogModule schema
interface BlogModule {
  totalArticles: number;           // Total published articles
  totalDrafts?: number;            // Unpublished drafts (optional)
  categories?: CategoryCount[];    // Article counts by category
  recentArticles?: ArticleSummary[]; // Most recent articles (up to 20)
  allArticles?: ArticleSummary[];  // Full published article list when available
  articleHistory?: ArticleHistoryPoint[]; // Cumulative article totals over time
  lastPublishedAt?: string;        // ISO timestamp of most recent publish
}

interface CategoryCount {
  name: string;    // Category name (e.g. "Engineering", "Product")
  count: number;   // Number of articles in this category
}

interface ArticleSummary {
  title: string;           // Article title
  slug: string;            // URL slug or path
  publishedAt: string;     // ISO timestamp
  createdAt?: string;      // Internal creation timestamp, if available
  category?: string;       // Primary category
  wordCount?: number;      // Approximate word count
}

interface ArticleHistoryPoint {
  date: string;            // ISO date for a daily or monthly point
  totalArticles: number;   // Cumulative published total at that point
}

The blog module is intentionally flexible. At minimum, include totalArticles. The other fields give Copper Analytics richer data for content dashboards but are all optional.

Copper Analytics stores blog snapshots over time on each successful sync, so articleHistory is optional. Include it only if you want to backfill older article growth before Copper Analytics has built enough stored history.

Include allArticles when you want the SEO page to show a per-post audit table. Each article can also expose an optional createdAt timestamp alongside the visible publishedAt date.

Tip

Populating blog data: Most blog frameworks already know article counts. For Next.js with MDX, read the content directory. For a CMS-backed blog, query the CMS API. For WordPress, use the wp-json/wp/v2/posts REST endpoint.

Full example response

A site exporting both roadmap and blog data:

GET /api/copper — example response
{
  "site": "myapp.com",
  "fetchedAt": "2026-03-11T14:30:00.000Z",
  "version": 1,
  "modules": {
    "roadmaps": {
      "items": [
        {
          "slug": "2026-03-10-dark-mode",
          "title": "Dark Mode Support",
          "status": "in-progress",
          "date": "2026-03-10",
          "priority": "high",
          "phase": 2,
          "tags": ["ui", "accessibility"],
          "description": "# Dark Mode Support\n\nAdd system-aware dark mode...",
          "summary": "# Dark Mode Support\n\nAdd system-aware dark mode...",
          "tasks": {
            "total": 5,
            "completed": 2,
            "remaining": 3,
            "percent": 40
          }
        }
      ]
    },
    "blog": {
      "totalArticles": 42,
      "totalDrafts": 3,
      "lastPublishedAt": "2026-03-09T10:00:00.000Z",
      "categories": [
        { "name": "Engineering", "count": 18 },
        { "name": "Product", "count": 12 },
        { "name": "Company", "count": 8 },
        { "name": "Tutorials", "count": 4 }
      ],
      "articleHistory": [
        { "date": "2025-12-01", "totalArticles": 31 },
        { "date": "2026-01-01", "totalArticles": 35 },
        { "date": "2026-02-01", "totalArticles": 39 },
        { "date": "2026-03-01", "totalArticles": 42 }
      ],
      "recentArticles": [
        {
          "title": "Introducing Dark Mode",
          "slug": "introducing-dark-mode",
          "publishedAt": "2026-03-09T10:00:00.000Z",
          "createdAt": "2026-03-08T18:15:00.000Z",
          "category": "Engineering",
          "wordCount": 1200
        },
        {
          "title": "Q1 Product Update",
          "slug": "q1-product-update",
          "publishedAt": "2026-03-01T09:00:00.000Z",
          "category": "Product",
          "wordCount": 800
        }
      ],
      "allArticles": [
        {
          "title": "Introducing Dark Mode",
          "slug": "introducing-dark-mode",
          "publishedAt": "2026-03-09T10:00:00.000Z",
          "createdAt": "2026-03-08T18:15:00.000Z",
          "category": "Engineering",
          "wordCount": 1200
        }
      ]
    }
  }
}

A site exporting only blog data (no roadmaps):

Minimal blog-only response
{
  "site": "myblog.com",
  "fetchedAt": "2026-03-11T14:30:00.000Z",
  "version": 1,
  "modules": {
    "blog": {
      "totalArticles": 87,
      "lastPublishedAt": "2026-03-10T08:00:00.000Z"
    }
  }
}

Reference implementations

Copy-paste these into your project. Each endpoint reads roadmap files from ./roadmaps/*.md and counts blog posts from ./content/blog/*.md (adjust the paths to match your project).

Next.js (App Router)

src/app/api/copper/route.ts
import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'

// pnpm add gray-matter

const API_KEY = process.env.COPPER_API_KEY!

function verifyAuth(request: NextRequest): boolean {
  const bearer = request.headers.get('authorization')?.replace('Bearer ', '')
  const header = request.headers.get('x-api-key')
  const query = request.nextUrl.searchParams.get('api_key')
  return (bearer || header || query) === API_KEY
}

function parseRoadmaps() {
  const dir = path.join(process.cwd(), 'roadmaps')
  if (!fs.existsSync(dir)) return { items: [] }

  const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'))
  const items = files.map(file => {
    const raw = fs.readFileSync(path.join(dir, file), 'utf-8')
    const { data, content } = matter(raw)
    const slug = file.replace(/\.md$/, '')
    const trimmed = content.trim()

    // Parse task lists
    const completed = (trimmed.match(/^\s*- \[x\]/gm) || []).length
    const incomplete = (trimmed.match(/^\s*- \[ \]/gm) || []).length
    const total = completed + incomplete

    return {
      slug,
      title: data.title || slug,
      status: data.status || 'proposed',
      date: data.date ? String(data.date) : slug.slice(0, 10),
      priority: data.priority || undefined,
      phase: typeof data.phase === 'number' ? data.phase : undefined,
      tags: Array.isArray(data.tags) ? data.tags : undefined,
      description: trimmed,
      summary: trimmed.slice(0, 500),
      tasks: total > 0
        ? { total, completed, remaining: total - completed, percent: Math.round((completed / total) * 100) }
        : undefined,
    }
  })

  items.sort((a, b) => b.date.localeCompare(a.date))
  return { items }
}

function parseBlog() {
  const dir = path.join(process.cwd(), 'content', 'blog')
  if (!fs.existsSync(dir)) return undefined

  const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') || f.endsWith('.mdx'))
  const articles = files.map(file => {
    const raw = fs.readFileSync(path.join(dir, file), 'utf-8')
    const { data, content } = matter(raw)
    return {
      title: data.title || file.replace(/\.mdx?$/, ''),
      slug: file.replace(/\.mdx?$/, ''),
      publishedAt: data.date ? new Date(data.date).toISOString() : undefined,
      createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : undefined,
      category: data.category || undefined,
      wordCount: content.split(/\s+/).length,
      draft: data.draft === true,
    }
  })

  const published = articles.filter(a => !a.draft)
  const drafts = articles.filter(a => a.draft)
  const sorted = published.sort((a, b) =>
    (b.publishedAt || '').localeCompare(a.publishedAt || '')
  )

  const categories: Record<string, number> = {}
  for (const a of published) {
    if (a.category) categories[a.category] = (categories[a.category] || 0) + 1
  }

  return {
    totalArticles: published.length,
    totalDrafts: drafts.length || undefined,
    lastPublishedAt: sorted[0]?.publishedAt || undefined,
    categories: Object.entries(categories).map(([name, count]) => ({ name, count })),
    recentArticles: sorted.slice(0, 20).map(({ draft, ...rest }) => rest),
    allArticles: sorted.map(({ draft, ...rest }) => rest),
  }
}

export async function GET(request: NextRequest) {
  if (!verifyAuth(request)) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const modules: Record<string, unknown> = {}

  const roadmaps = parseRoadmaps()
  if (roadmaps.items.length > 0) modules.roadmaps = roadmaps

  const blog = parseBlog()
  if (blog) modules.blog = blog

  return NextResponse.json({
    site: 'yoursite.com',  // Replace with your domain
    fetchedAt: new Date().toISOString(),
    version: 1,
    modules,
  })
}

Express / Node.js

routes/copper.js
const fs = require('fs')
const path = require('path')
const matter = require('gray-matter')

// npm install gray-matter

const API_KEY = process.env.COPPER_API_KEY

function verifyAuth(req) {
  const bearer = (req.headers.authorization || '').replace('Bearer ', '')
  const header = req.headers['x-api-key']
  const query = req.query.api_key
  return (bearer || header || query) === API_KEY
}

module.exports = function (app) {
  app.get('/api/copper', (req, res) => {
    if (!verifyAuth(req)) {
      return res.status(401).json({ error: 'Unauthorized' })
    }

    const modules = {}

    // --- Roadmaps module ---
    const roadmapsDir = path.join(process.cwd(), 'roadmaps')
    if (fs.existsSync(roadmapsDir)) {
      const files = fs.readdirSync(roadmapsDir).filter(f => f.endsWith('.md'))
      const items = files.map(file => {
        const raw = fs.readFileSync(path.join(roadmapsDir, file), 'utf-8')
        const { data, content } = matter(raw)
        const slug = file.replace(/\.md$/, '')
        const trimmed = content.trim()

        return {
          slug,
          title: data.title || slug,
          status: data.status || 'proposed',
          date: data.date ? String(data.date) : slug.slice(0, 10),
          priority: data.priority,
          phase: data.phase,
          tags: data.tags,
          description: trimmed,
          summary: trimmed.slice(0, 500),
        }
      })
      items.sort((a, b) => b.date.localeCompare(a.date))
      if (items.length > 0) modules.roadmaps = { items }
    }

    // --- Blog module ---
    const blogDir = path.join(process.cwd(), 'content', 'blog')
    if (fs.existsSync(blogDir)) {
      const files = fs.readdirSync(blogDir).filter(f => /\.mdx?$/.test(f))
      const articles = files.map(file => {
        const raw = fs.readFileSync(path.join(blogDir, file), 'utf-8')
        const { data, content } = matter(raw)
        return {
          title: data.title || file.replace(/\.mdx?$/, ''),
          slug: file.replace(/\.mdx?$/, ''),
          publishedAt: data.date ? new Date(data.date).toISOString() : undefined,
          createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : undefined,
          category: data.category,
          wordCount: content.split(/\s+/).length,
          draft: data.draft === true,
        }
      })

      const published = articles.filter(a => !a.draft)
      published.sort((a, b) => (b.publishedAt || '').localeCompare(a.publishedAt || ''))

      modules.blog = {
        totalArticles: published.length,
        lastPublishedAt: published[0]?.publishedAt,
        recentArticles: published.slice(0, 20).map(({ draft, ...rest }) => rest),
        allArticles: published.map(({ draft, ...rest }) => rest),
      }
    }

    res.json({
      site: 'yoursite.com',  // Replace with your domain
      fetchedAt: new Date().toISOString(),
      version: 1,
      modules,
    })
  })
}

Warning

Store your COPPER_API_KEY as an environment variable — never commit it to source control.

How Copper Analytics uses it

Once your site exposes /api/copper and you configure the URL + token in the site integration settings, Copper Analytics will:

1

Fetch your /api/copper endpoint periodically (approximately every 15 minutes)

2

Parse each module in the response and validate against the expected schema

3

Cache the response in a snapshot collection with a 15-minute TTL

4

Display the data in the appropriate dashboard sections — roadmaps under Planning, blog stats under Content, and aggregate views across all your sites

Tip

Copper Analytics processes each module independently. If your roadmaps module has valid data but blog has a schema error, Copper Analytics will still import the roadmap data and log a warning for the blog module.

Migrating from /api/roadmap

If your site already exposes GET /api/roadmap using the standalone Copper Roadmap Format, migration is straightforward:

1

Create the new endpoint

Add GET /api/copper that wraps your existing roadmap data inside the envelope format. Your roadmap parsing logic stays the same — just nest the items array under modules.roadmaps.

2

Update the URL in Copper Analytics

Go to your site's integration settings and change the endpoint URL from /api/roadmap to /api/copper. The token and auth mode stay the same.

3

Add more modules

Now you can add blog or any future module to the same endpoint. No additional configuration needed in Copper Analytics.

Before (standalone roadmap endpoint)
// GET /api/roadmap
{
  "site": "myapp.com",
  "fetchedAt": "2026-03-11T14:30:00.000Z",
  "items": [...]
}
After (Copper Data API)
// GET /api/copper
{
  "site": "myapp.com",
  "fetchedAt": "2026-03-11T14:30:00.000Z",
  "version": 1,
  "modules": {
    "roadmaps": {
      "items": [...]   // Same items array, just nested
    },
    "blog": {
      "totalArticles": 42
    }
  }
}

Note

You can keep /api/roadmap running alongside /api/copper during the transition. Copper Analytics will use whichever endpoint you configure in the site settings.

Adding new modules in the future

As Copper Analytics supports more data types, new module schemas will be documented on this page. Your site can adopt new modules at any pace — just add the key to your modules object. No changes to the endpoint URL, authentication, or configuration are needed.