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: 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.
/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 be1(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:
| 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
blog module
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
wp-json/wp/v2/posts REST endpoint.Full example response
A site exporting both roadmap and blog data:
{
"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):
{
"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)
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
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
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 periodically (approximately every 15 minutes)
Parse each module in the response and validate against the expected schema
Cache the response in a snapshot collection with a 15-minute TTL
Display the data in the appropriate dashboard sections — roadmaps under Planning, blog stats under Content, and aggregate views across all your sites
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.