Recommended setups

How to build a website on Copper CMS.

Before you pick one

The delivery key lives on a server. The site never calls the delivery API while a visitor is waiting for a page.

A read is a request, not an article. Fetching one page of the list is one read, and fetching one article is one read. It does not matter how many articles come back. Think of the read allowance as a build budget rather than a traffic budget.

The allowance is per account per month, and it is the same on Free and Pro. See Plans and limits.

Over the allowanceStill served. The response carries copper-usage: reads-over-limit.
Past three times the allowanceRefused with 402.

Every response from the delivery API carries a cache header.

cache-control: private, max-age=60, stale-while-revalidate=3600

It is private on purpose. The url does not name a site, so the key alone decides what comes back, and a shared cache keyed on the url would hand one account's articles to another. Cache on the server that holds the key.

A static site rebuilt on publish

Use a static site generator such as Astro or Eleventy, and a host such as Cloudflare Pages or Netlify.

The build calls allPosts and writes one page per article, and the host serves the files. When something is published, a webhook from Copper CMS calls the deploy hook of the host and the host rebuilds.

const copper = createClient({ apiKey: process.env.COPPER_KEY })

for await (const post of copper.allPosts()) {
  await writePage(post)
}
async function onWebhook(rawBody, headers) {
  if (!await verifyWebhook({ secret, rawBody,
        signature: headers['x-copper-signature'],
        timestamp: headers['x-copper-timestamp'] })) return 401

  await fetch(process.env.DEPLOY_HOOK, { method: 'POST' })
  return 200
}

What it costs in reads. A visitor never touches the delivery API. Reads track how often you publish, not how much traffic the site gets, and each rebuild spends a read per page of the list.

When to use it. When the whole site can be served as files, and a short wait between publishing and going live is fine.

The trade-off. That wait. Every rebuild walks the whole catalogue, so the wait grows as the catalogue does.

A server that revalidates on the event

Next.js on the App Router is the example here. Pages are rendered on the server and cached, and a webhook route verifies each message and clears the cached entry, so the next visitor gets a fresh render. The article goes live with no rebuild.

// app/blog/[slug]/page.tsx
const copper = createClient({ apiKey: process.env.COPPER_KEY })

export default async function Page({ params }) {
  const post = await copper.getPost(params.slug)
  if (!post) notFound()
  return <Article post={post} />
}
// app/api/copper/route.ts
export async function POST(request) {
  const rawBody = await request.text()
  const ok = await verifyWebhook({ secret, rawBody,
    signature: request.headers.get('x-copper-signature'),
    timestamp: request.headers.get('x-copper-timestamp') })
  if (!ok) return new Response(null, { status: 401 })

  const event = JSON.parse(rawBody)
  if (await alreadyHandled(event.id)) return new Response(null, { status: 200 })

  if (event.data?.slug) revalidatePath(`/blog/${event.data.slug}`)
  revalidatePath('/blog')

  await markHandled(event.id)
  return new Response(null, { status: 200 })
}

What it costs in reads. Reads track invalidations, and a visitor served from the cache never touches the delivery API.

Keep a time based revalidation as a fallback. If a message is missed, the page is stale only until the timer fires.

When to use it. When a rebuild is too slow. That happens with a large catalogue, or with search and filtering that run across the whole catalogue.

The trade-off. A server that runs all the time, and the first visitor after an invalidation waits for the render.

A mirror in a store you run

The webhook handler verifies the message, fetches the article with getPost, and writes it into a table or key value store that you run. The site reads from that store, so Copper CMS is on the write path and never on the read path.

async function onWebhook(rawBody, headers) {
  if (!await verifyWebhook({ secret, rawBody,
        signature: headers['x-copper-signature'],
        timestamp: headers['x-copper-timestamp'] })) return 401

  const event = JSON.parse(rawBody)
  if (await alreadyHandled(event.id)) return 200
  if (event.event === 'ping') return 200

  if (event.event === 'post.unpublished') {
    await removeBySlug(event.data.slug)
  } else {
    const post = await copper.getPost(event.data.slug)
    if (post) await upsert(post)
  }

  await markHandled(event.id)
  return 200
}

What it costs in reads. Reads track events and the catch-up, and traffic never reaches Copper CMS.

When to use it. Under heavy traffic, when syndicating to several sites, or when articles have to be joined to data you already hold.

The trade-off. More moving parts. A store, a handler and a catch-up job all have to run.

Rules for every setup

Keep the key on a server. A key in a browser bundle is public. Each key names one site, and it is shown once at issue and never again.

Verify every message, and pass the raw body. The id at the top of the body names the event, not the attempt, and every retry and every replay carries the same id. Process an id once and drop any repeat. A ping is delivered but cannot be subscribed to. See Outbound webhooks.

Run a catch-up on a timer. Delivery is retried and then given up on, and an endpoint that keeps failing is switched off. The retry figures are on Outbound webhooks.

for await (const post of copper.allPosts({ since })) {
  await upsert(post)
}

since filters on publication time. The catch-up repairs a missed publication, but it does not pick up an edit to an older article and it does not pick up a removal. For those, walk the whole list and compare updatedAt.

A site in Copper CMS is just a name. It holds no url and no timezone, because the host owns the routing. Slugs are unique per site, so the same article can carry a suffix on a second site.

Images upload once and are served from cdn.coppercms.com.