# How to Send Calendly Booking Push Notifications to a Shared Room

> Verify Calendly's signed booking webhook, keep invitee details private, and send one fixed Ping when a new meeting is booked.

- Published: 2026-10-23
- Author: PingRoom
- Category: Developers
- Topics: Calendly booking push notifications, Calendly webhook notifications, Calendly new booking alert
- Canonical: https://pingroom.io/blog/calendly-booking-push-notifications
- Cover image: https://pingroom.io/blog/calendly-booking-push-notifications-cover.webp
- Cover description: Two PingRoom avatars sort one blank booking token beside Pingo at a miniature scheduling desk.

To send a Calendly booking notification to a shared phone-alert Room, subscribe a small HTTPS relay to Calendly's **`invitee.created`** event. The relay should verify Calendly's timestamped signature, accept only the event type you chose, discard invitee details, and send one fixed message to a PingRoom incoming webhook.

This guide sends **New Calendly booking — Product demo booked a meeting.** The invitee name, email, answers, time, location, and meeting link stay out of the Ping. It is a webhook recipe you configure, not a native Calendly integration.

## Choose the shortest useful route

Calendly already sends calendar and email updates. It also offers a [native Slack integration](https://calendly.com/integration/slack) for booking, cancellation, and rescheduling notifications. Keep those when they reach the right people.

| Need                                                     | Better fit                                    |
| -------------------------------------------------------- | --------------------------------------------- |
| The host needs the normal meeting record                 | Calendly's calendar and email updates         |
| A Slack channel already handles scheduling alerts        | Calendly's native Slack integration           |
| A workflow builder already has approved access and rules | Its Calendly trigger plus a controlled action |
| A selected PingRoom Room needs one short phone alert     | The signed, privacy-minimal relay below       |

The Room is an attention path, not a second calendar. Calendly remains the source of truth for the invitee, meeting time, location, questions, cancellation, and rescheduling state.

## Decide exactly what the Room learns

Use a non-sensitive label such as **Product demo**, **Volunteer orientation**, or **Office hours**. The relay combines only that fixed label with fixed copy:

> **New Calendly booking**
>
> Product demo booked a meeting.

That sentence says a new booking event passed the relay's checks. It does not identify the invitee, expose a meeting link, or claim that a person reviewed the booking.

[Pingo the Pigeon](/Pingo), our fictional CMO, appears beside a closed appointment ledger and one blank booking token in the illustrations. His deadpan contribution is to keep the ledger closed. He cannot inspect Calendly, enter a private Room, or send a Ping; the human team controls the subscription, copy, Room membership, and response.

## Prepare the Calendly and PingRoom ends

Calendly's [API FAQ](https://developer.calendly.com/docs/getting-started/frequently-asked-questions) says the user covered by a webhook subscription needs a paid Standard, Teams, or Enterprise plan. Your token also needs the current scheduled-events read and webhook write permissions. Use a user-scoped subscription for one user's events, or an organization-scoped subscription only when the account role and intended audience justify the broader scope.

The destination Room's owner needs active **PingRoom Pro** for incoming webhooks. Room members who want native alerts need PingRoom on a supported device with notifications allowed.

Follow the [incoming webhook setup](/blog/webhook-to-push-notification): open the Room's **Settings → Connections → From another app**, add a webhook named **Calendly**, choose **Custom message**, and copy its URL. The complete URL is a credential. Store it as a server-side secret; never put it in Calendly fields, browser code, source control, screenshots, or logs.

Set these relay secrets:

- `CALENDLY_WEBHOOK_SIGNING_KEY`: a long random value used only for this subscription.
- `CALENDLY_EVENT_TYPE_URI`: the exact Calendly event-type URI you intend to alert on.
- `CALENDLY_EVENT_LABEL`: approved fixed copy such as `Product demo`.
- `PINGROOM_WEBHOOK_URL`: the complete incoming webhook URL for the selected Room.

Create the Calendly subscription with its [v2 webhook endpoint](https://developer.calendly.com/api-docs/calendly-api/webhooks/create-webhook-subscription). This illustrative user-scoped request supplies the same signing key to Calendly and to your relay:

```bash
curl --fail-with-body --silent --show-error \
  -X POST "https://api.calendly.com/webhook_subscriptions" \
  -H "Authorization: Bearer $CALENDLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"url\":\"$RELAY_URL\",\"events\":[\"invitee.created\"],\"organization\":\"$CALENDLY_ORGANIZATION_URI\",\"user\":\"$CALENDLY_USER_URI\",\"scope\":\"user\",\"signing_key\":\"$CALENDLY_WEBHOOK_SIGNING_KEY\"}"
```

Do not print that command with real values in shared shell history or logs. An OAuth application follows Calendly's OAuth-specific signing-key flow instead of choosing one this way.

## Verify, filter, and forward

Calendly signs the untouched body and sends a `Calendly-Webhook-Signature` header containing a Unix timestamp and one or more `v1` signatures. This Node-compatible handler parses the header defensively, checks a three-minute freshness window, verifies HMAC-SHA256, accepts only a first booking for one event type, and hashes the invitee URI into a stable PingRoom idempotency key.

```javascript
import { createHash, createHmac, timingSafeEqual } from "node:crypto";

function parseSignature(value) {
  const timestamps = [];
  const signatures = [];
  for (const part of (value || "").split(",")) {
    const [key, ...rest] = part.trim().split("=");
    const item = rest.join("=");
    if (key === "t" && /^\d+$/.test(item)) timestamps.push(Number(item));
    if (key === "v1" && /^[0-9a-f]{64}$/i.test(item)) {
      signatures.push(item.toLowerCase());
    }
  }
  if (timestamps.length !== 1 || signatures.length === 0) return null;
  return { timestamp: timestamps[0], signatures };
}

function secureEqualHex(left, right) {
  const a = Buffer.from(left, "hex");
  const b = Buffer.from(right, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

function validPingRoomUrl(value) {
  return /^https:\/\/api\.pingroom\.io\/api\/webhooks\/[^/?#\s]+\/[^/?#\s]+$/.test(
    value || "",
  );
}

export async function handleCalendly(
  request,
  env,
  fetchImpl = fetch,
  now = () => Date.now(),
) {
  if (request.method !== "POST") {
    return new Response("method not allowed", { status: 405 });
  }

  const label = (env.CALENDLY_EVENT_LABEL || "").trim();
  const expectedEventType = env.CALENDLY_EVENT_TYPE_URI || "";
  if (
    !env.CALENDLY_WEBHOOK_SIGNING_KEY ||
    !/^https:\/\/api\.calendly\.com\/event_types\/[^/?#\s]+$/.test(
      expectedEventType,
    ) ||
    !label ||
    Array.from(label).length > 70 ||
    /[\r\n\t]/.test(label) ||
    !validPingRoomUrl(env.PINGROOM_WEBHOOK_URL)
  ) {
    return new Response("server configuration error", { status: 500 });
  }

  const rawBody = await request.text();
  if (Buffer.byteLength(rawBody, "utf8") > 65536) {
    return new Response("payload too large", { status: 413 });
  }

  const parsed = parseSignature(
    request.headers.get("calendly-webhook-signature"),
  );
  const currentSeconds = Math.floor(now() / 1000);
  if (!parsed || Math.abs(currentSeconds - parsed.timestamp) > 180) {
    return new Response("invalid signature", { status: 401 });
  }

  const expected = createHmac("sha256", env.CALENDLY_WEBHOOK_SIGNING_KEY)
    .update(`${parsed.timestamp}.`)
    .update(rawBody)
    .digest("hex");
  if (!parsed.signatures.some((item) => secureEqualHex(item, expected))) {
    return new Response("invalid signature", { status: 401 });
  }

  let event;
  try {
    event = JSON.parse(rawBody);
  } catch {
    return new Response("invalid JSON", { status: 400 });
  }

  if (
    event.event !== "invitee.created" ||
    event.payload?.rescheduled === true
  ) {
    return new Response("ignored", { status: 202 });
  }
  if (event.payload?.scheduled_event?.event_type !== expectedEventType) {
    return new Response("ignored", { status: 202 });
  }

  const inviteeUri = event.payload?.uri;
  if (
    typeof inviteeUri !== "string" ||
    !/^https:\/\/api\.calendly\.com\/scheduled_events\/[^/?#\s]+\/invitees\/[^/?#\s]+$/.test(
      inviteeUri,
    )
  ) {
    return new Response("invalid event", { status: 400 });
  }

  const digest = createHash("sha256").update(inviteeUri).digest("hex");
  const eventKey = `calendly-${digest}`;
  const pingBody = JSON.stringify({
    action: null,
    title: "New Calendly booking",
    message: `${label} booked a meeting.`,
    correlation_id: eventKey,
  });

  let pingResponse;
  try {
    pingResponse = await fetchImpl(env.PINGROOM_WEBHOOK_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": eventKey,
      },
      body: pingBody,
      redirect: "error",
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return new Response("upstream unavailable", { status: 502 });
  }

  let accepted = false;
  try {
    accepted = (await pingResponse.json()).success === true;
  } catch {
    // A non-JSON response cannot confirm acceptance.
  }
  if (pingResponse.status !== 200 || !accepted) {
    return new Response("upstream unavailable", { status: 502 });
  }

  return new Response("accepted", { status: 200 });
}
```

The [signature guide](https://developer.calendly.com/api-docs/overview/webhooks/webhook-signatures) defines the signed message as the timestamp, a period, and the raw body. The freshness check limits replay from an old captured request; the stable key handles a legitimate repeat of the same current event without creating a second Ping.

The handler deliberately ignores `invitee.created` events marked `rescheduled`. A replacement booking created during a reschedule should not look like an unrelated new lead in this Room. Use Calendly's own record for the full reschedule sequence.

![A PingRoom avatar sorts one accepted blank booking token while another keeps a duplicate aside as Pingo watches a small sand timer.](/blog/calendly-booking-push-notifications-1.webp)

_AI-generated editorial illustration. Pingo is PingRoom's fictional mascot; the people and workflow are illustrative._

## Test the acceptance boundary

Before subscribing an active event type, use harmless fixtures and verify:

1. A current, correctly signed `invitee.created` event for the selected event type creates one Ping.
2. The Ping body contains no invitee name, email, answers, time, location, meeting link, or raw Calendly URI.
3. Replaying the same event produces no second Ping.
4. A stale timestamp, changed byte, malformed header, or wrong key returns `401` and sends nothing.
5. Another event type, cancellation, or rescheduled replacement returns `202` and sends nothing.
6. A PingRoom rejection makes the relay return a non-2xx response so Calendly can retry.

For this article, we ran the exact embedded handler locally with signed Calendly fixtures and a stateful mock of PingRoom's documented webhook contract. The check covered valid delivery, replay, a second invitee, multiple `v1` values, malformed and stale signatures, malformed JSON, oversized input, event and event-type filters, reschedule exclusion, invalid configuration, upstream rejection, malformed upstream output, and network failure. It made no live Calendly, PingRoom, or phone request.

## Know what a successful request proves

Calendly's [webhook error guide](https://developer.calendly.com/api-docs/overview/webhooks/webhook-errors) says failed deliveries may be retried with exponential backoff for up to 24 hours, and a persistently failing subscription can be disabled. Monitor the relay and the subscription rather than treating the phone alert as the durable record.

PingRoom permits up to four incoming webhooks per Room, applies the Room's webhook cooldown, and enforces the current limits in the [webhook documentation](/documents/webhooks). A response with HTTP `200` and `success: true` means PingRoom accepted and queued the Ping. It does not guarantee that every device displayed an alert under every network, Focus, mute, battery, or notification setting.

If scheduling controls healthcare, legal, safeguarding, financial, emergency, or regulated work, retain the approved calendar, case system, audit record, and escalation path. This short alert is an attention cue, not proof that someone saw, accepted, or attended the meeting.

Start with one harmless Calendly event type and a small [PingRoom Room](/app/rooms). Keep the fixed message boring enough for a lock screen and useful enough for the people you invited.
