CU-17: Slack notifications & outbound webhooks (verified via webhook.site)

Priority: P2 Accounts/sessions: P1 host (ravikantguptaofficial@gmail.com) logged into Calendo via "Sign in with Google"; same Gmail used for invitee plus-aliases. Parallel-safe: Yes — this suite creates its own throwaway event type and bookings scoped by RUNID and never edits global host availability. Exclusive (rewrites global host availability?): No. Estimated time: 30 minutes. L3 reality checks: webhook.site request inbox (POST receipt + JSON body + X-Calendo-Signature/X-Calendo-Event headers) for booking.created, booking.cancelled, booking.rescheduled; Slack channel message rendering IF a real Slack incoming webhook is available (otherwise Slack rendering is manual residue, but the outbound POST path is still proven via webhook.site).

Goal

This suite proves that Calendo's outbound integrations actually fire to the outside world when bookings change. Specifically: (1) a user-registered outbound webhook receives a real signed HTTP POST when a booking is created, cancelled, and rescheduled, with the correct JSON payload and HMAC signature header; and (2) the Slack incoming-webhook integration saves, passes its built-in "Test", and delivers a formatted message on a real booking. These are the integration points that let teams wire Calendo into their own tooling (Zapier, internal services, Slack channels). If the POST never arrives, or arrives without a valid signature, downstream automations silently break — so end-to-end external delivery, not just a UI toast, is what we verify.

Preconditions

Test data

Steps

A. Set up endpoints

  1. Action: Open a new browser tab and go to https://webhook.site. Wait for it to assign a unique URL. Copy the value shown as "Your unique URL" (top of page) into WH_URL. Expect: A page titled with a UUID, an empty request list, and a copyable URL of the form https://webhook.site/<uuid>. [L1] — Capture screenshot: cu17-01-webhooksite-fresh.
  1. Action: Switch to the dashboard tab (https://calendo.dev/dashboard/). Click the Overview sidebar tab (.sidebar-nav a[data-tab="overview"]). Read the booking link text from the share bar (#bookingLinkValue). Record HOST_SLUG = the substring after user=. Expect: A non-empty URL like https://calendo.dev/booking/?user=<HOST_SLUG>. [L1]/[L2]
  1. Action (only if no usable event type): On Overview click + New (#overviewCreateEtBtn) to open the edit modal (#editEtModal). Fill name (#editEtName) = Webhook Test <RUNID>, set duration (#editEtDuration) = 30, then click Create (#editEtSaveBtn). Wait ~1.5s. Expect: #editEtModal closes and #overviewEventTypes now contains Webhook Test <RUNID>. Record EVENT_SLUG = webhook-test-<RUNID>. [L1]/[L2] — Capture screenshot: cu17-02-eventtype-created.
  1. Action — register the outbound webhook (API-only). On the dashboard tab open DevTools → Console and run exactly: ``js await fetch('/api/webhooks', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: '<WH_URL>', events: ['booking.created','booking.cancelled','booking.rescheduled'] }) }).then(r => r.json()) ` Substitute WH_URL. **Expect:** A JSON object echoed in the console with url = your WH_URL, events = "[\"booking.created\",\"booking.cancelled\",\"booking.rescheduled\"]", a non-empty secret, is_active: 1, and a numeric id. Record WEBHOOK_ID = id and WEBHOOK_SECRET = secret. (POST /api/webhooks returns 201; id is needed for cleanup.) **[L1]/[L2]** — Capture screenshot: cu17-03-webhook-registered`.
  1. Action — confirm persistence. In the console run: ``js await fetch('/api/webhooks', { credentials: 'include' }).then(r => r.json()) ` **Expect:** An array containing exactly one (or more) entries; one entry's url equals WH_URL and is_active is 1`. [L2]

B. booking.created

  1. Action: Open a new tab to the public booking page: https://calendo.dev/booking/?user=<HOST_SLUG>&event=<EVENT_SLUG>. Expect: The booking view (#booking-view) renders a calendar with at least one selectable day (.calendar-day.available). If none this month, click the next-month nav (.calendar-nav-btn — the last one) up to 6 times. If still none, flag a precondition failure (availability). [L1]
  1. Action: Click the first available day (.calendar-day.available), then click the first time slot (.time-slot). Fill name (#input-name) = Webhook Invitee <RUNID> and email (#input-email) = ravikantguptaofficial+inv-<RUNID>@gmail.com. Click Confirm (#btn-confirm). Expect: The confirmation view shows title (.confirmation-title) containing "Booking Confirmed". [L1] — Capture screenshot: cu17-04-booking-confirmed. Note the cancel and reschedule links in #confirmation-manage (these are /booking/cancel.html?token=... and /booking/reschedule.html?token=...); keep this tab open or copy both hrefs — you need them for steps 11 and 14.
  1. Action — L3 webhook receipt for booking.created. Switch to the webhook.site tab and refresh/observe the request list (it updates live). Open the newest POST request. Expect:
    • HTTP method POST, content-type application/json.
    • Request headers include X-Calendo-Event: booking.created and a non-empty X-Calendo-Signature (a 64-char lowercase hex HMAC-SHA256).
    • Request body JSON = { "booking": { "uid": "...", "event_type": "Webhook Test <RUNID>", "invitee_name": "Webhook Invitee <RUNID>", "invitee_email": "ravikantguptaofficial+inv-<RUNID>@gmail.com", "start_time": "...", "end_time": "...", "status": "...", "timezone": "..." } }. (UTM keys appear only if the booking URL carried UTM params; not expected here.) [L3] — Capture screenshot: cu17-05-webhooksite-created showing both the headers panel and the JSON body.

C. booking.cancelled

  1. Action: Go to the cancel page using the link captured in step 7: https://calendo.dev/booking/cancel.html?token=<TOKEN>. Expect: The cancel form (#cancel-form) shows the event name, host, and date/time matching the booking. [L1]/[L2]
  1. Action: (Optional) type a reason in #reason, then click Cancel Booking (#cancel-btn). Expect: The success state (#success-state) becomes visible confirming cancellation. [L1] — Capture screenshot: cu17-06-cancel-success.
  1. Action — L3 webhook receipt for booking.cancelled. Switch to webhook.site and open the newest POST. Expect:
    • Header X-Calendo-Event: booking.cancelled, non-empty X-Calendo-Signature.
    • Body JSON { "booking": { "id": <n>, "uid": "...", "invitee_name": "Webhook Invitee <RUNID>", "invitee_email": "ravikantguptaofficial+inv-<RUNID>@gmail.com", "start_time": "...", "end_time": "...", "status": "cancelled", "cancelled_by": "host" } }. (Note: the cancel payload uses cancelled_by: "host" and includes id; it does NOT include event_type/timezone — that is expected.) [L3] — Capture screenshot: cu17-07-webhooksite-cancelled.

D. booking.rescheduled (needs a fresh active booking)

  1. Action: Repeat step 6–7 to create a SECOND booking with invitee email ravikantguptaofficial+inv-<RUNID>-r@gmail.com (use a different slot than before). Expect: "Booking Confirmed" again; capture its reschedule link from #confirmation-manage. [L1] — Capture screenshot: cu17-08-second-booking-confirmed.
  1. Action: Switch to webhook.site and confirm a fresh booking.created POST arrived for this second booking (header X-Calendo-Event: booking.created, invitee email ends -r@gmail.com). Expect: New POST present. [L3]
  1. Action: Open the reschedule page: https://calendo.dev/booking/reschedule.html?token=<TOKEN>. Pick a new available day (.calendar-day.available) and a new time (.time-slot); on the confirm view click Confirm Reschedule (#btn-reschedule). Expect: Success state (#success-state) becomes visible. [L1]/[L2] — Capture screenshot: cu17-09-reschedule-success.
  1. Action — L3 webhook receipt for booking.rescheduled. Switch to webhook.site and open the newest POST. Expect:
    • Header X-Calendo-Event: booking.rescheduled, non-empty X-Calendo-Signature.
    • Body JSON { "booking": { "uid": "...", "previous_uid": "...", "event_type": "Webhook Test <RUNID>", "invitee_name": "...", "invitee_email": "...-r@gmail.com", "start_time": "<new>", "end_time": "<new>", "previous_start_time": "<old>", "previous_end_time": "<old>", "status": "...", "timezone": "..." } }. Confirm start_time differs from previous_start_time (proves the move was captured). [L3] — Capture screenshot: cu17-10-webhooksite-rescheduled.

E. Slack integration (conditional)

  1. Action (only if a real https://hooks.slack.com/... URL is available): Dashboard → Settings sidebar tab → Integrations sub-tab (button.settings-tab[onclick*="integrations"]). In the Slack Notifications card paste the real Slack URL into #slackWebhookUrl and click Save (#saveSlackWebhookBtn). Expect: #slackConnectionStatus text becomes "Connected" (green) and the Test button (#testSlackWebhookBtn) becomes visible. [L1]/[L2] — Capture screenshot: cu17-11-slack-saved.
    • If saving fails / no real Slack URL: STOP this section. Record that Slack save was skipped because the worker only accepts URLs starting with https://hooks.slack.com/ (a webhook.site URL is rejected with "Invalid Slack webhook URL"). Mark Slack delivery as manual residue and proceed to Cleanup.
  1. Action: Click Test (#testSlackWebhookBtn). Expect: A toast "Test message sent to Slack". The worker POSTs a blocks payload whose header text is :white_check_mark: Calendo Connected! and only returns success if Slack returns 2xx — so a toast means a real 2xx round-trip occurred. [L1]/[L2]
  1. Action — L3 Slack test message. Open the Slack channel/workspace tied to that incoming webhook. Expect: A message rendered with header "✅ Calendo Connected!" and body "Your Slack webhook is working...". [L3] — Capture screenshot: cu17-12-slack-test-msg.
  1. Action — L3 Slack booking.created. Create one more booking (repeat steps 6–7, invitee ...+inv-<RUNID>-s@gmail.com). Expect: A Slack message with header ":calendar: New Booking" listing Event = Webhook Test <RUNID>, Invitee, and When (UTC), plus an "Open Dashboard" button linking to https://calendo.dev/dashboard/. [L3] — Capture screenshot: cu17-13-slack-new-booking. (This same booking also fires a webhook.site booking.created — optionally confirm it.)

L3 reality checks

Cleanup

  1. Delete the outbound webhook. On the dashboard DevTools console run: ``js await fetch('/api/webhooks/<WEBHOOK_ID>', { method: 'DELETE', credentials: 'include' }).then(r => r.json()) ` Expect { ok: true }. Then re-run GET /api/webhooks and confirm WH_URL is no longer listed. (If multiple webhooks were created across reruns, delete each one whose url` matches your WH_URL.)
  2. Cancel any still-active bookings created by this run: open each booking's cancel link (or in the dashboard Bookings tab click the red Cancel button cancelBooking(<id>) / .btn-danger on each RUNID-tagged row). The first booking was already cancelled in step 10; cancel the second/third (the rescheduled one and the Slack booking). Note: cancelling these will fire additional booking.cancelled webhook.site POSTs — that is expected and harmless after the webhook is deleted in step 1 (delete the webhook FIRST to avoid noise, OR cancel first then delete — either is fine; just don't leave the webhook registered).
  3. Delete the throwaway event type Webhook Test <RUNID> (if you created it): Overview → open the event type → delete it via its delete control. Skip if you reused an existing event type.
  4. Clear the Slack webhook (only if you set it AND it was test-only): Settings → Integrations → clear #slackWebhookUrl and click Save; status should return to "Not configured". Do NOT clear a Slack URL that P1 uses in production — if unsure, leave it and note it.
  5. webhook.site needs no cleanup (ephemeral). Close the tab.

Pass/Fail criteria

The run PASSES only if ALL of these hold:

The run FAILS if: any expected POST never appears in webhook.site, an X-Calendo-Signature header is missing/empty, the event name header is wrong, the payload omits required fields or has the wrong RUNID invitee, or the Slack Test toasts success but no message renders in a real channel (when Slack is in scope).

Evidence to capture

Manual residue / cannot-verify