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:
| Concern | Multiple endpoints | Single endpoint |
|---|---|---|
| Configuration | URL + token per data type | One URL + one token for everything |
| Adding a new data type | New endpoint + new config in Copper Analytics | Add a key to the response — no config change |
| Network requests | N requests per sync (one per type) | 1 request per sync |
| Token management | Potentially different tokens per endpoint | One token, one rotation |
Note
/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:
interface CopperDataResponse {
site: string; // Your site's domain (e.g. "myapp.com")
fetchedAt: string; // ISO timestamp of when this response was generated
version: 2; // Schema version (2 = current, 1 = legacy)
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.
/api/copperReturns 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, andversionin every response - Should respond within 10 seconds (Copper Analytics applies a timeout per site)
versionmust be2(version1is still supported for backward compatibility)
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:
| Mode | How Copper Analytics sends the credential |
|---|---|
| Bearer token | Authorization: Bearer <token> |
| Custom header | x-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
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 (v2)
blog module
interface BlogModule {
articles: Article[]; // Flat list of published articles
}
interface Article {
slug: string; // URL slug or path
title: string; // Article title
publishedAt: string; // ISO timestamp
category?: string; // Primary category (e.g. "Engineering")
wordCount?: number; // Approximate word count
createdAt?: string; // Internal creation timestamp
excerpt?: string; // Short summary for display
}Just return your articles as a flat array. Copper Analytics derives everything else: total counts, category breakdowns, publication velocity, content depth distribution, freshness signals, and historical growth charts.
Include wordCount for content depth metrics (thin/short/medium/long analysis). Include category for category coverage breakdowns. Include createdAt alongside publishedAt if your articles have a separate internal creation date.
Tip
wp-json/wp/v2/posts REST endpoint. Just return the flat array — Copper Analytics handles all the computation.Note
blog (v1 — legacy)
The v1 blog module format (with pre-computed totalArticles, categories, articleHistory, etc.) is still supported for backward compatibility. If your site already returns v1, it will continue to work. However, v2 is recommended because:
- Simpler implementation — just return your article list
- Copper Analytics computes richer metrics than v1 sites can provide (content depth, freshness, velocity)
- Dashboard data is always fresh (60s TTL cache vs manual sync)
Full example response
A site exporting both roadmap and blog data:
{
"site": "myapp.com",
"fetchedAt": "2026-03-11T14:30:00.000Z",
"version": 2,
"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": {
"articles": [
{
"slug": "introducing-dark-mode",
"title": "Introducing Dark Mode",
"publishedAt": "2026-03-09T10:00:00.000Z",
"createdAt": "2026-03-08T18:15:00.000Z",
"category": "Engineering",
"wordCount": 1200
},
{
"slug": "q1-product-update",
"title": "Q1 Product Update",
"publishedAt": "2026-03-01T09:00:00.000Z",
"category": "Product",
"wordCount": 800
}
]
}
}
}A site exporting only blog data (no roadmaps):
{
"site": "myblog.com",
"fetchedAt": "2026-03-11T14:30:00.000Z",
"version": 2,
"modules": {
"blog": {
"articles": [
{
"slug": "getting-started",
"title": "Getting Started Guide",
"publishedAt": "2026-03-10T08:00:00.000Z",
"category": "Tutorial",
"wordCount": 950
}
]
}
}
}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)
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()
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 {
slug: file.replace(/\.mdx?$/, ''),
title: data.title || file.replace(/\.mdx?$/, ''),
publishedAt: data.date ? new Date(data.date).toISOString() : '',
createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : undefined,
category: data.category || undefined,
wordCount: content.split(/\s+/).length,
draft: data.draft === true,
}
})
.filter(a => !a.draft)
return { articles: articles.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: 2,
modules,
})
}Express / Node.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 (v2: just return articles) ---
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 {
slug: file.replace(/\.mdx?$/, ''),
title: data.title || 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,
}
})
.filter(a => !a.draft)
modules.blog = {
articles: articles.map(({ draft, ...rest }) => rest),
}
}
res.json({
site: 'yoursite.com', // Replace with your domain
fetchedAt: new Date().toISOString(),
version: 2,
modules,
})
})
}Warning
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:
Fetch your /api/copper endpoint on demand when a dashboard page loads (cached for 60 seconds)
Parse the raw articles array and compute all metrics in real-time — categories, velocity, depth, freshness, and history
Display the data in the appropriate dashboard sections — roadmaps under Planning, blog stats under SEO and Content Metrics
Tip
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:
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.
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.
Add more modules
Now you can add blog or any future module to the same endpoint. No additional configuration needed in Copper Analytics.
// GET /api/roadmap
{
"site": "myapp.com",
"fetchedAt": "2026-03-11T14:30:00.000Z",
"items": [...]
}// 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
/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.