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
- P1 must be logged into Calendo in the browser (persisted session) at https://calendo.dev/dashboard/. Confirm by loading the dashboard and seeing
#welcomeHeaderand the sidebar (.sidebar-nav a[data-tab="overview"]). If you land on/auth/login.htmlinstead, STOP and flag a precondition failure per 00-setup-preconditions.md — do NOT attempt a cold email/password or Google login. - P1 must have at least one active event type and baseline availability so the public booking page renders bookable slots. If the overview share bar (
#bookingLinkBar/#bookingLinkValue) is empty or no slots exist, this suite creates its own event type (below). If even that fails to produce a bookable slot, flag a precondition failure. - A second browser tab to https://webhook.site is required and must load (it auto-generates a unique URL). If webhook.site is blocked/unreachable, flag — do not substitute an arbitrary endpoint.
- Slack (optional): If P1 has a real Slack incoming webhook URL (
https://hooks.slack.com/services/...) available, the L1/L2 Slack save+test and the L3 Slack message-rendering check are in scope. If NO real Slack webhook is available, skip the Slack-save steps (the worker rejects any non-hooks.slack.comURL, so webhook.site cannot stand in for the Slack save endpoint) and record Slack-channel rendering as manual residue. The webhook.site path alone fully covers outbound delivery + signature. - IMPORTANT — webhook registration is API-only. There is NO dashboard form for outbound webhooks (the Integrations settings panel has Slack, PayPal, API Keys, and AI BYOK cards only). You will register/delete the webhook by running an authenticated
fetch()in the browser DevTools console on the dashboard page; the page's session cookie is sent automatically (credentials:'include'). Do not improvise a different host.
Test data
- RUNID: pick a fresh UTC token at execution time, e.g.
20260601-1530. Embed it in every created name/slug/email. - Event type (create if needed): name
Webhook Test <RUNID>, duration30. Its slug will bewebhook-test-<RUNID>(lowercased, hyphenated). If you reuse an existing event type instead, record which slug you used. - Host slug (HOST_SLUG): read from
#bookingLinkValueon the overview (it holds the full booking URL; the slug is the value afteruser=). - Invitee email:
ravikantguptaofficial+inv-<RUNID>@gmail.com(lands in P1's Gmail). - Invitee name:
Webhook Invitee <RUNID>. - webhook.site URL (WH_URL): the unique URL shown at the top of the webhook.site tab, e.g.
https://webhook.site/<uuid>. Copy it exactly. - Webhook secret: auto-generated by Calendo on registration; capture it from the POST response (you need it only to note that a signature is present; full HMAC recomputation is manual residue).
- Slack webhook URL (optional): the real
https://hooks.slack.com/services/...value if available.
Steps
A. Set up endpoints
- 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.
- 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 afteruser=. Expect: A non-empty URL likehttps://calendo.dev/booking/?user=<HOST_SLUG>. [L1]/[L2]
- 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:#editEtModalcloses and#overviewEventTypesnow containsWebhook Test <RUNID>. Record EVENT_SLUG =webhook-test-<RUNID>. [L1]/[L2] — Capture screenshot:cu17-02-eventtype-created.
- 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 withurl= your WH_URL,events="[\"booking.created\",\"booking.cancelled\",\"booking.rescheduled\"]", a non-emptysecret,is_active: 1, and a numericid. Record WEBHOOK_ID =idand WEBHOOK_SECRET =secret. (POST/api/webhooksreturns 201;idis needed for cleanup.) **[L1]/[L2]** — Capture screenshot:cu17-03-webhook-registered`.
- 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'surlequals WH_URL andis_activeis1`. [L2]
B. booking.created
- 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]
- 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.
- 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-typeapplication/json. - Request headers include
X-Calendo-Event: booking.createdand a non-emptyX-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-createdshowing both the headers panel and the JSON body.
- HTTP method
C. booking.cancelled
- 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]
- 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.
- Action — L3 webhook receipt for booking.cancelled. Switch to webhook.site and open the newest POST. Expect:
- Header
X-Calendo-Event: booking.cancelled, non-emptyX-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 usescancelled_by: "host"and includesid; it does NOT includeevent_type/timezone— that is expected.) [L3] — Capture screenshot:cu17-07-webhooksite-cancelled.
- Header
D. booking.rescheduled (needs a fresh active booking)
- 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.
- Action: Switch to webhook.site and confirm a fresh
booking.createdPOST arrived for this second booking (headerX-Calendo-Event: booking.created, invitee email ends-r@gmail.com). Expect: New POST present. [L3]
- 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.
- Action — L3 webhook receipt for booking.rescheduled. Switch to webhook.site and open the newest POST. Expect:
- Header
X-Calendo-Event: booking.rescheduled, non-emptyX-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": "..." } }. Confirmstart_timediffers fromprevious_start_time(proves the move was captured). [L3] — Capture screenshot:cu17-10-webhooksite-rescheduled.
- Header
E. Slack integration (conditional)
- 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#slackWebhookUrland click Save (#saveSlackWebhookBtn). Expect:#slackConnectionStatustext 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.
- If saving fails / no real Slack URL: STOP this section. Record that Slack save was skipped because the worker only accepts URLs starting with
- 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]
- 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.
- 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 tohttps://calendo.dev/dashboard/. [L3] — Capture screenshot:cu17-13-slack-new-booking. (This same booking also fires a webhook.sitebooking.created— optionally confirm it.)
L3 reality checks
- webhook.site (primary L3): For each of
booking.created,booking.cancelled,booking.rescheduled, open the matching POST in the webhook.site request list and confirm (a) theX-Calendo-Eventheader equals the expected event name, (b) a non-emptyX-Calendo-Signatureheader is present (64 hex chars), and (c) the JSON body matches the per-event shape in steps 8/11/15, including the RUNID-scoped invitee email. There is no Gmail search for webhooks — webhook.site IS the external receiver. - Slack (conditional L3): In the Slack channel bound to the incoming webhook, confirm the "Calendo Connected!" test message (step 18) and the ":calendar: New Booking" message (step 19) actually render. If no real Slack workspace is available, this is NOT performed — record it under Manual residue.
- Gmail cross-check (supporting, not the webhook proof): Optionally open https://mail.google.com (P1) and search
inv-<RUNID>to see the invitee confirmation/cancellation/reschedule emails arrived; this corroborates the booking actions but is the email path, not the webhook path. Treat as supporting evidence only.
Cleanup
- 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-runGET /api/webhooksand confirm WH_URL is no longer listed. (If multiple webhooks were created across reruns, delete each one whoseurl` matches your WH_URL.) - 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-dangeron 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 additionalbooking.cancelledwebhook.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). - 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. - Clear the Slack webhook (only if you set it AND it was test-only): Settings → Integrations → clear
#slackWebhookUrland 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. - webhook.site needs no cleanup (ephemeral). Close the tab.
Pass/Fail criteria
The run PASSES only if ALL of these hold:
- An outbound webhook was registered via
POST /api/webhooksreturning 201 with a non-emptysecretandis_active: 1, and it appeared inGET /api/webhooks[L2]. - webhook.site received a
booking.createdPOST with headerX-Calendo-Event: booking.created, a non-emptyX-Calendo-Signature, and a body matching the created shape with the RUNID invitee email [L3]. - webhook.site received a
booking.cancelledPOST withX-Calendo-Event: booking.cancelled, a signature, and body containing"status":"cancelled"and"cancelled_by":"host"[L3]. - webhook.site received a
booking.rescheduledPOST withX-Calendo-Event: booking.rescheduled, a signature, and body wherestart_time≠previous_start_timeandprevious_uidis present [L3]. - The public UI confirmed each action (Booking Confirmed / cancel success / reschedule success) [L1].
- IF a real Slack webhook was available: save shows "Connected", the Test button appears, the Test click toasts success, and the Slack channel renders both the "Calendo Connected!" and ":calendar: New Booking" messages [L1/L2/L3]. (If no Slack webhook, this criterion is N/A and Slack rendering is recorded as manual residue — the run can still pass on the webhook.site criteria.)
- Cleanup completed: the webhook is deleted, all RUNID bookings are cancelled, the throwaway event type (if created) is deleted.
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
cu17-01-webhooksite-fresh,cu17-02-eventtype-created,cu17-03-webhook-registered(console response),cu17-04-booking-confirmed.cu17-05-webhooksite-created,cu17-06-cancel-success,cu17-07-webhooksite-cancelled.cu17-08-second-booking-confirmed,cu17-09-reschedule-success,cu17-10-webhooksite-rescheduled.- (Slack, if in scope)
cu17-11-slack-saved,cu17-12-slack-test-msg,cu17-13-slack-new-booking. - Notes: WEBHOOK_ID, WEBHOOK_SECRET (presence only), HOST_SLUG, EVENT_SLUG, the three captured
X-Calendo-Signaturevalues (first 8 chars suffice), and the exact invitee emails used. - Cleanup confirmation note: webhook DELETE returned
{ok:true}andGET /api/webhooksno longer lists WH_URL.
Manual residue / cannot-verify
- HMAC signature correctness. The agent confirms a signature header is present; it cannot recompute HMAC-SHA256(body, secret) in-browser to prove the value is valid. A human (or a tiny script) should verify one signature against the captured WEBHOOK_SECRET out of band. (Algorithm: HMAC-SHA256 over the raw JSON body, hex-encoded, compared to
X-Calendo-Signature.) - Slack channel rendering when no real Slack workspace is available. Because the worker rejects any non-
hooks.slack.comURL, webhook.site cannot stand in for the Slack save endpoint; if P1 has no real Slack incoming webhook, the actual Slack-channel message rendering is handed to the human. The webhook.site path still fully proves Calendo's outbound POST behavior. - Webhook retry / auto-disable behavior (failure_count increments, auto-deactivation after 5 failures) is not exercised here — that needs an endpoint that returns non-2xx and is out of scope (TBD).
- Whether deleting a test-only Slack URL is safe depends on whether P1 uses that Slack webhook in production; the agent should leave it if unsure and flag for the human.