# Guide to Planning Center Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | The Webhooks UI at `https://api.planningcenteronline.com/webhooks`, or an OAuth application |
| Ownership | Per user. Deliveries only include data that user can see |
| Method | `POST` only |
| Signature header | `X-PCO-Webhooks-Authenticity` |
| Scheme | HMAC-SHA256, bare lowercase hex |
| Signed content | The raw request body, nothing else |
| Secret | The subscription's `authenticity_secret`, a 64-character hex string used as text |
| Event name | `data[].attributes.name` in the body |
| Success response | `200` only |
| Retries | Up to 16 times after the first failure, over about 5.4 days |
| Deactivation | After 16 consecutive failures, at once on a `410`, or after a very high failure count in 24 hours |
| `Retry-After` | Honoured on `429` and `503`, capped at 1 hour |
| Redelivery | Manual, from the Webhooks UI |
| Rate limits | None 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:

```json
{
  "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.

```javascript
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:

```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](/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](/docs/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](/docs/transformations) 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Planning Center webhooks reliably in minutes.