Gareth Wilson Gareth Wilson

Guide to Planning Center Webhooks: Features and Best Practices

Published


Planning Center webhooks tell your systems when something changes in a church's Planning Center account: a person's details are updated, a service plan changes, a donation comes in. If you build integrations on Planning Center's People, Services, Giving, Check-Ins, Groups or Calendar apps, webhooks are how those changes reach your own services without polling the API.

This guide covers how Planning Center webhook subscriptions are created and signed, the shape of an EventDelivery and its nested payload, the retry rules that can switch a subscription off, and the best practices for running a Planning Center receiver in production.

What are Planning Center webhooks?

A Planning Center webhook subscription is a URL registered for one event, such as people.v2.events.person.updated. Subscriptions belong to a user. Any user who can log in can create them at https://api.planningcenteronline.com/webhooks, and an OAuth application can create them on a user's behalf. Either way, deliveries "only return data you have permission to see within Planning Center", so what arrives depends on the user who owns the subscription.

Every delivery is a POST with a JSON:API body and an X-PCO-Webhooks-Authenticity header. That header holds a hex HMAC-SHA256 of the raw body, keyed with the subscription's authenticity_secret.

Planning Center webhook features

FeatureDetails
ConfigurationThe Webhooks UI at https://api.planningcenteronline.com/webhooks, or an OAuth application
OwnershipPer user. Deliveries only include data that user can see
MethodPOST only
Signature headerX-PCO-Webhooks-Authenticity
SchemeHMAC-SHA256, bare lowercase hex
Signed contentThe raw request body, nothing else
SecretThe subscription's authenticity_secret, a 64-character hex string used as text
Event namedata[].attributes.name in the body
Success response200 only
RetriesUp to 16 times after the first failure, over about 5.4 days
DeactivationAfter 16 consecutive failures, at once on a 410, or after a very high failure count in 24 hours
Retry-AfterHonoured on 429 and 503, capped at 1 hour
RedeliveryManual, from the Webhooks UI
Rate limitsNone for webhooks, at present

Planning Center doesn't state a response timeout or publish source IPs.

Common events

Event names follow the pattern <app>.<api version>.events.<resource>.<action>. The public docs name only one, people.v2.events.person.updated, in their worked example. The full list of events you can subscribe to is in the Webhooks UI after you log in, so copy names from there rather than guessing them.

Because the API version is part of the name, a new payload version arrives as a new event name rather than as a change to an existing one.

Here is the shape of Planning Center's example delivery, with the nested payload shortened:

{
  "data": [
    {
      "id": "cac783dc-75e7-4c5b-88e8-502d9d8682ae",
      "type": "EventDelivery",
      "attributes": {
        "name": "people.v2.events.person.updated",
        "attempt": 1,
        "payload": "{\"data\":{\"type\":\"Person\",\"id\":\"125587227\",\"attributes\":{\"first_name\":\"Kid\",\"last_name\":\"222\",\"status\":\"active\",\"updated_at\":\"2023-06-19T14:50:30Z\"}, ...},\"included\":[],\"meta\":{\"parent\":{\"id\":\"127\",\"type\":\"Organization\"}}}"
      },
      "relationships": {
        "organization": { "data": { "type": "Organization", "id": "127" } }
      }
    }
  ]
}

Three details matter here:

  • data is an array. The example has one element, and Planning Center doesn't say whether a delivery can carry more, so loop over it.
  • attributes.payload is a string, not an object. It holds the changed resource as its own JSON:API document, so you parse it a second time.
  • Each EventDelivery has a unique id and an attempt number. Planning Center says to use them "for logging and surfacing helpful messaging".

Setting up Planning Center webhooks

  1. Log in at https://api.planningcenteronline.com/webhooks as the user whose data the integration should see.
  2. Create a subscription for each event you need, pointing at your HTTPS endpoint.
  3. Copy the subscription's authenticity secret and store it server-side, for example as PLANNING_CENTER_AUTHENTICITY_SECRET.
  4. Make a change in Planning Center that fires the event, then check the request and your response in the Webhooks UI, which shows both for every delivery.

If you receive Planning Center webhooks through Hookdeck, create a source with the Planning Center type, paste the authenticity secret exactly as shown, and use the source URL as the subscription URL.

Securing Planning Center webhooks

Compute an HMAC-SHA256 of the raw request body with the authenticity secret as the key, hex-encode it, and compare it with X-PCO-Webhooks-Authenticity in constant time.

The secret is 64 hex characters, which invites you to hex-decode it into 32 bytes. Don't. Planning Center's Ruby sample passes the string straight to OpenSSL::HMAC.hexdigest, so the key is the text itself.

const crypto = require('crypto');
const express = require('express');

function verifyPlanningCenterSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  // The secret is used as text, not hex-decoded.
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/planning-center', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.PLANNING_CENTER_AUTHENTICITY_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyPlanningCenterSignature(req.body, req.get('x-pco-webhooks-authenticity'), secret)) {
    return res.sendStatus(401); // never 410, which deactivates the subscription
  }

  const body = JSON.parse(req.body.toString('utf8'));
  for (const delivery of Array.isArray(body.data) ? body.data : []) {
    const { name, attempt } = delivery.attributes;
    const resource = JSON.parse(delivery.attributes.payload); // parse the nested string
    // Deduplicate on delivery.id, then route on name.
  }
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac


def verify_planning_center_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(
        signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8")
    )


# In FastAPI: raw_body = await request.body()
# verify_planning_center_signature(raw_body, request.headers.get("x-pco-webhooks-authenticity"),
#                                  os.environ["PLANNING_CENTER_AUTHENTICITY_SECRET"])

Planning Center's Authenticity section prints a secret, a full request body and the MAC it should produce, a0782fde663e7ee25a99b9d44f45434120ff027ba98ef333a83a4590fcc97828. Copy the body exactly as it appears inside the Ruby single quotes, backslashes included, and you have a test case for your verifier.

Only the body is signed and there is no timestamp, so there is no freshness check to make.

Make Planning Center webhooks production-ready. Hookdeck Event Gateway verifies the X-PCO-Webhooks-Authenticity header, answers Planning Center with a 200, and durably queues every delivery before your service sees it.

Planning Center webhook limitations and pain points

A hex-looking secret that isn't hex-decoded

The Problem: Every delivery fails verification with the correct secret.

Why It Happens: Some vendors issue a hex string and expect you to decode it into key bytes. Planning Center's 64-character secret looks exactly like one of those, but it is used as text. Decoding it produces a different key and a signature that never matches.

Workarounds:

  • Pass the secret to your HMAC function as a string.
  • Test your verifier against the worked example in Planning Center's docs.

How Hookdeck Can Help: Hookdeck's Planning Center source uses the secret as text and verifies every request in one place. Hookdeck answers Planning Center with a 200 either way, then records a request that fails the check as failed verification, so it never reaches your destination.

One wrong status code switches the subscription off

The Problem: A subscription stops delivering, and nobody changed it.

Why It Happens: Responding 410 deactivates the subscription immediately. Some frameworks and proxies return 410 for removed routes. Planning Center also deactivates after 16 consecutive failures, about 5.4 days after the first, or after "a very high number of delivery failures" within 24 hours. Failure emails only go to subscriptions a user created, not ones an OAuth application created.

Workarounds:

  • Never return 410 from the webhook route, even for events you no longer want. Unsubscribe in the Webhooks UI instead.
  • Return exactly 200, not 201 or 204. Planning Center says anything other than a 200 is retried.
  • If your app creates subscriptions through OAuth, monitor them yourself, because nobody gets the failure emails.

How Hookdeck Can Help: Hookdeck accepts each delivery and answers Planning Center with a 200 straight away, so errors and outages on your side don't count towards deactivation, and Hookdeck retries delivery to your service on your own schedule.

The payload is a string inside JSON

The Problem: delivery.attributes.payload.data is undefined.

Why It Happens: payload is a JSON-encoded string, so reading fields off it gets you nothing. Filtering or transforming on the resource's fields has to decode it first.

Workarounds:

  • JSON.parse the payload after parsing the outer body.
  • Validate both levels separately and log the raw string when the inner parse fails.

How Hookdeck Can Help: A Hookdeck transformation can parse payload into an object before delivery, so your handler and any filters work on structured data.

The event catalogue is behind a login

The Problem: You can't plan an integration from the public docs alone.

Why It Happens: The public webhook guide names a single event. The rest are listed in the Webhooks UI, which needs a Planning Center account.

Workarounds:

  • Log in and copy event names from the UI.
  • Route on the <app>.<version>.events.<resource>.<action> parts so new events fall into sensible handlers, and log anything unrecognised.

How Hookdeck Can Help: Hookdeck stores every delivery it receives, so the first real events for each subscription are there to inspect in full before you write handlers for them.

Best practices

Verify against the raw body

The MAC covers the exact bytes Planning Center sent, and the nested payload string is full of escaped quotes that re-serialising can change. In Express, use express.raw({ type: '*/*' }). In Next.js, use await request.text(). In FastAPI, use await request.body().

Deduplicate on the EventDelivery id

Retries and manual redeliveries repeat a delivery. Store each id before processing and skip any you have seen. See our guide to webhook idempotency.

Use Retry-After for planned slowdowns

If your service is overloaded, respond 429 or 503 with a Retry-After header. Planning Center waits up to an hour, but it still counts the attempt as a failure, so use it for short pauses only.

Choose the subscription owner carefully

Deliveries carry only what the owning user can see. Create subscriptions under an account with the permissions your integration needs, and expect fields to be missing when that user's access is narrower.

Acknowledge fast and process asynchronously

Verify, store the delivery, return 200, and do the work afterwards. See why to process webhooks asynchronously.

Conclusion

Planning Center signs every delivery with a hex HMAC-SHA256 of the raw body in X-PCO-Webhooks-Authenticity, keyed with the subscription's authenticity secret used as text. Loop over the data array, parse each payload string a second time, deduplicate on the EventDelivery id, and answer with a plain 200, never a 410.

Hookdeck Event Gateway sits in front of your receiver, verifies every Planning Center signature, and holds each delivery until your service has processed it, so an outage on your side doesn't deactivate the subscription.

Get started with Hookdeck for free and handle Planning Center webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.