← All guides
CalendlyJuly 12, 20265 min read

Test Calendly Webhooks on Localhost with Relayers

Receive, route, and transform Calendly webhooks on your calendly webhook localhost setup — no ngrok, no redeploys, and signature verification still works.

You wired up a Calendly webhook subscription, pointed it at your app, and now every invitee.created event vanishes into a public endpoint you can't step through with a debugger. To iterate, you push to staging, wait for a deploy, book a fake meeting, and stare at logs. That loop is slow, and it gets slower every time you tweak the handler.

The fix is to bring Calendly's events straight to your machine. Relayers gives you a stable public ingest URL, forwards the original request — body and headers, untouched — down a tunnel to localhost, and lets you filter and reshape payloads before they ever hit your code. Because the raw bytes and the Calendly-Webhook-Signature header arrive intact, your signature verification runs locally exactly as it will in production.

What you'll build

Calendly delivers to one durable Relayers URL. Relayers applies your routing rules, optionally transforms the payload, and streams each event through the CLI tunnel to whatever port your app runs on.

Prerequisites

1Create an endpoint in Relayers

Open the Relayers desktop app and go to Endpoints → Create. Name it something like calendly-dev. You'll get a public ingest URL shaped like:

https://api.relayers.app/v1/webhooks/wep_9f3c2ab1d4e5

Copy it. Before touching Calendly, hit Send test to push a sample request through the pipeline so you can confirm the plumbing works end to end. The payload inspector and Events view will show it arrive.

2Create a webhook subscription in Calendly

Calendly has no dashboard toggle for webhooks — subscriptions are created through the API. POST to the Webhook Subscriptions endpoint with your Relayers URL, the events you care about, your scope, and a signing_key so deliveries are signed.

curl -X POST https://api.calendly.com/webhook_subscriptions \
  -H "Authorization: Bearer $CALENDLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.relayers.app/v1/webhooks/wep_9f3c2ab1d4e5",
    "events": ["invitee.created", "invitee.canceled"],
    "organization": "https://api.calendly.com/organizations/AAAAAAAAAAAAAAAA",
    "scope": "organization",
    "signing_key": "your-random-signing-secret"
  }'

From now on, every time someone books or cancels, Calendly signs the request with an HMAC-SHA256 digest and sends it in the Calendly-Webhook-Signature header — a Stripe-style t=<timestamp>,v1=<signature> value you verify against your signing_key.

The payload looks like this:

{
  "event": "invitee.created",
  "payload": {
    "email": "sam@example.com",
    "name": "Sam Rivera",
    "scheduled_event": {
      "start_time": "2026-07-20T15:00:00.000000Z"
    }
  }
}

3Forward events to localhost

Log in and start the tunnel from the CLI:

relayers login
relayers listen --forward localhost:3000

relayers listen opens a WebSocket tunnel and forwards each event to your local port. Book a test meeting on your Calendly link and watch the request land in your terminal and in the app's Events view. If your handler is broken, set a breakpoint and re-run — no redeploys, no waiting.

4Route and filter with JQ

You probably don't want cancellations hitting your "send welcome email" handler. In the Rules tab, add a JQ filter so only invitee creations flow through:

{ "event": "invitee.created" }

A rule matches when the JQ expression's output matches the event. Set the rule's destination to your tunnel (localhost:3000) or a public URL. Rules are evaluated top to bottom and first match wins, so you can add a second rule for invitee.canceled pointing at a different port or service and reorder them to control precedence.

5Transform the payload

Calendly's envelope is nested. If your handler just wants the essentials, add a Transformation (jq, jsonata, javascript, or go_template) to flatten it before delivery:

{
  event: .event,
  name: .payload.name,
  email: .payload.email,
  start: .payload.scheduled_event.start_time
}

Given the sample above, your app now receives:

{
  "event": "invitee.created",
  "name": "Sam Rivera",
  "email": "sam@example.com",
  "start": "2026-07-20T15:00:00.000000Z"
}

Use the test playground to run your transform against a real captured payload until the output is exactly what your handler expects.

Signature verification still works

A common gotcha with webhook proxies is that they re-encode the body or drop headers, which breaks HMAC checks. Relayers forwards the original body and headers untouched, so the Calendly-Webhook-Signature header reaches localhost intact and your verification code — recomputing the HMAC-SHA256 over the raw payload with your signing_key — behaves locally exactly as it does in production. Note that if you apply a transformation, verify the signature against the raw event before the transform, or run verification on the untransformed copy.

Going to production

When you're ready, point a rule's destination at your deployed public URL instead of the tunnel and stop the CLI. Nothing else changes on Calendly's side — the subscription URL stays the same. Even better, use Export to save your endpoint, rules, and transformations as a JSON bundle, then Import it into your production or staging setup so the config is reproducible instead of hand-rebuilt.

Troubleshooting

Wrap-up

With one stable Relayers endpoint, a JQ filter, and an optional transform, Calendly's invitee.created and invitee.canceled events land on your localhost in seconds — signed, filtered, and shaped the way your handler wants them. Build the whole integration with a debugger attached, then flip the destination to production without touching Calendly again.

Ready to try it? Head to relayers.app and download the app to start testing your calendly webhook localhost flow on the free tier.