Outbound webhooks

Copper CMS posts to a url when an article changes.

Add an endpoint

Open Outbound webhooks in the console. Give a url. Pick the events. Pick a site or all sites.

The signing secret is displayed at creation and never again.

The url must be https.

Events

EventWhen it fires
post.publishedAn article goes live on a site. Also when a deleted article is restored.
post.updatedThe body of a live article changes. Editing a draft sends nothing.
post.unpublishedA live article is unpublished from a site or archived or deleted.

A message is sent per site. An article on many sites fires for each of them.

There is no post.deleted event. A deleted article sends post.unpublished.

The request

POST https://example.com/hooks/copper
content-type: application/json
x-copper-signature: 4f1c9b7e…
x-copper-timestamp: 2026-08-29T09:00:00.000Z
x-copper-event-id: 5c0f2a71-8e3d-4b91-a0c7-1d2e3f4a5b6c
x-copper-event: post.published

{
  "id": "5c0f2a71-8e3d-4b91-a0c7-1d2e3f4a5b6c",
  "event": "post.published",
  "siteId": "9b1e…",
  "deliveredAt": "2026-08-29T09:00:00.000Z",
  "data": {
    "id": "7d2a…",
    "slug": "a-new-office-in-lisbon",
    "title": "A new office in Lisbon",
    "excerpt": "One line of summary.",
    "tags": ["news"],
    "authorName": "Jane Doe",
    "publishedAt": "2026-08-29T08:59:12.000Z",
    "updatedAt": "2026-08-29T08:59:12.000Z"
  }
}

Answer with a 2xx. Any other answer counts as a failure.

Answer quickly. An endpoint that takes longer than the timeout is abandoned for that attempt. Queue the work and answer.

The payload names the article

The payload has no bodyHtml. Fetch the article with a delivery key.

const post = await copper.getPost(payload.data.slug)

The payload is built when the message is queued. The fetched article is current.

The data field is absent on a ping. It holds an id alone when the article left the site before the attempt went out. Check for slug before reading the rest.

A url that moves

An article that changes slug arrives as a post.unpublished carrying the old slug and a post.published carrying the new one. Remove the row under the old slug and write the row under the new one. The two are queued together and either can arrive first, which is safe: they name different urls.

This is the only signal a url moved. There is no redirect on our side and nothing that rewrites a link that is already out there.

Check the signature

The signature covers the timestamp and the raw body joined by a dot.

import { verifyWebhook } from '@coppercms/sdk'

const ok = await verifyWebhook({
  secret: process.env.COPPER_WEBHOOK_SECRET,
  rawBody,
  signature: headers['x-copper-signature'],
  timestamp: headers['x-copper-timestamp'],
})
if (!ok) return respond(401)

Pass the body exactly as it arrived. Parsing it and turning it back into JSON changes the bytes. The check then fails.

Any language can do the same.

const signed = `${timestamp}.${rawBody}`
const expected = createHmac('sha256', secret).update(signed).digest('hex')

// compare in constant time
// reject a timestamp outside the tolerance window

The headers are not covered by the signature. Act on the body.

Duplicate messages

The id at the top of the body names the event. It does not name the attempt. Every retry carries the same id. A replay carries it too.

Process an id the first time. Drop a repeat.

Do not use deliveredAt for this. It changes on every attempt.

Ordering

Messages are sent oldest first. A retry rejoins the queue behind newer messages. A post.updated can arrive before the post.published it belongs to.

Fetch the current article on every event. Write what comes back.

Retries

Attempts per event8
First wait30 seconds
Wait growthDoubles
Longest wait32 minutes
Given up on afterAbout 1 hour
Failures in a row before switch off5
Attempt history kept7 days

A success resets the failure count. Only failures with no success between them switch an endpoint off.

A switched off endpoint sends nothing. Restart it in the console.

Send a test

Send test on the endpoint page posts a signed ping.

{ "id": "…", "event": "ping", "deliveredAt": "…" }

A ping has no data. It is signed like any other message.

A ping is never retried. It never changes the failure count. Testing an endpoint cannot switch it off.

Replay

Every attempt is listed on the endpoint page. Replay queues an event again with the same id.

Replay appears once an event has stopped retrying.

Catching up

A webhook is a notification. The list endpoint is the record.

An event is given up on after the retries run out. Nothing is sent at all while an endpoint is switched off. Run a sweep on a timer.

const since = await lastSyncedAt()
for await (const post of copper.allPosts({ since })) {
  await upsert(post)
}
await setLastSyncedAt(now)

since filters on publication time. That sweep finds articles published after the last run. It does not find an edit to an older article. It does not find a removal.

To catch edits and removals walk the whole list and compare.

for await (const post of copper.allPosts()) {
  await upsertIfNewer(post)      // compare updatedAt
  seen.add(post.slug)
}
await removeEverythingNotIn(seen)

A handler

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
}