Agent skill

Planning Center Webhooks Skill

Receive and verify Planning Center (church management) webhooks. Use when setting up Planning Center webhook subscription handlers, debugging X-PCO-Webhooks-Authenticity signature verification, or handling EventDelivery events such as people.v2.events.person.updated.

Install this skill

npx skills add hookdeck/webhook-skills --skill planning-center-webhooks


When to Use This Skill

  • How do I receive Planning Center webhooks?
  • How do I verify the X-PCO-Webhooks-Authenticity signature?
  • Why does my Planning Center webhook signature verification fail?
  • How do I handle people.v2.events.person.updated and other EventDelivery events?
  • How do I read the nested attributes.payload JSON string?
  • What status codes make Planning Center retry or deactivate a subscription?

How Planning Center Webhooks Work

  • Webhook subscriptions are created at https://api.planningcenteronline.com/webhooks by any user who can log in (or by an OAuth application on a user's behalf). They only deliver data that user can see.
  • Deliveries are POST requests. The signature is in the X-PCO-Webhooks-Authenticity header.
  • The body is a JSON:API document: data is an array of EventDelivery objects. Each has attributes.name (the event, e.g. people.v2.events.person.updated), attributes.attempt, and attributes.payload, which is a JSON string that must be parsed a second time.

Signature Verification (core)

X-PCO-Webhooks-Authenticity is the bare hex HMAC-SHA256 of the raw request body, keyed with the subscription's authenticity_secret. Use the secret as a plain UTF-8 string. It looks like 64 hex characters, but do not hex-decode it.

const crypto = require('crypto');

function verifyPlanningCenter(rawBody, signature, secret) {
  // Check the format first: Buffer.from(x, 'hex') silently truncates invalid hex
  if (typeof signature !== 'string' || !/^[0-9a-f]{64}$/i.test(signature)) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  return crypto.timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}
import hashlib, hmac

def verify_planning_center(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    # Bytes on both sides: a non-ASCII header gives False (401), not a TypeError (500)
    return hmac.compare_digest(expected.encode(), signature.lower().encode())

In Express, read the raw body with express.raw({ type: '*/*' }) so any content type verifies. There is no timestamp in the signature; use EventDelivery.id for idempotency (see below).

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/. The tests reproduce the worked example from Planning Center's docs.

Event Names

The public docs name one event, taken from their worked example:

EventTriggered When
people.v2.events.person.updatedA Person record changes in Planning Center People

Event names follow <app>.<api version>.events.<resource>.<action>. The full catalog of subscribable events is shown in the Webhooks UI after login (and via the authenticated available_events API); it is not published on the public docs, so check it there rather than guessing names. See references/overview.md.

Handling the Payload

for (const delivery of body.data) {            // EventDelivery objects
  const { name, attempt, payload } = delivery.attributes;
  const event = JSON.parse(payload);           // nested JSON string
  // event.data is the changed resource, e.g. { type: 'Person', id: '125587227', attributes: {...} }
}

Responses and Retries

  • Respond 200 to acknowledge. Any other status is retried up to 16 times (30 s, 1 min, 2 min, ... up to once per day), then the subscription is deactivated.
  • Responding 410 deactivates the subscription immediately. Don't return it by accident.
  • 429 or 503 with a Retry-After header is honored (capped at 1 hour).
  • Use delivery.id to deduplicate retries and manual redeliveries.

Environment Variables

# From https://api.planningcenteronline.com/webhooks -> your subscription
PLANNING_CENTER_AUTHENTICITY_SECRET=your_authenticity_secret_here

Local Development

npx hookdeck-cli listen 3000 planning-center --path /webhooks/planning-center

No account required. Use the Hookdeck URL as the subscription URL.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 6, 2026

View on GitHub →