Skip to main content
All posts
Developers7 min read

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.

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 for booking, cancellation, and rescheduling notifications. Keep those when they reach the right people.

NeedBetter fit
The host needs the normal meeting recordCalendly's calendar and email updates
A Slack channel already handles scheduling alertsCalendly's native Slack integration
A workflow builder already has approved access and rulesIts Calendly trigger plus a controlled action
A selected PingRoom Room needs one short phone alertThe 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, 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 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: 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. This illustrative user-scoped request supplies the same signing key to Calendly and to your relay:

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.

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

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 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. 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. Keep the fixed message boring enough for a lock screen and useful enough for the people you invited.

PingRoom

Short-form communication.

View all posts