# 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.

```javascript
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'));
}

```

```python
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/planning-center-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/planning-center-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/planning-center-webhooks/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:

| Event | Triggered When |
| --- | --- |
| `people.v2.events.person.updated` | A 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/planning-center-webhooks/references/overview.md).

## Handling the Payload

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

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

```

## Local Development

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

```

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

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/planning-center-webhooks/references/overview.md) - Webhook concepts, payload structure, event naming
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/planning-center-webhooks/references/setup.md) - Creating a subscription and finding the authenticity secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/planning-center-webhooks/references/verification.md) - Signature details, worked example, gotchas