Our AEs walk into demos with a one-page brief: who the prospect is, what their company does, the last 3 form fills, signal from their public site, the ICP score, and a suggested opening question. That brief lands as a Slack DM exactly 30 minutes before the call. The workflow that builds it: Calendly webhook → HubSpot lookup → Apollo refresh → Claude Sonnet generates the brief → Slack DM. 16 nodes on n8n v1.121. Cost per brief: ₹3.80. AE prep time dropped from ~12 minutes to under 2 minutes of skim.
## TL;DR
A prospect books a demo via your Calendly link. Calendly fires an
## Prerequisites
Verify Calendly's signature first. Calendly signs with HMAC-SHA256 over the raw body, header
Then immediately respond 200 OK to Calendly so they do not retry. The brief work happens asynchronously.
The
An IF node routes accordingly. Saves us ~40% of Apollo credits on repeat prospects.
c) Homepage scrape — HTTP Request to the company's homepage (extracted from HubSpot's
30 min
Pre-call DM delivery target
₹3.80
Cost per brief
10 min
AE prep time saved per call
16
n8n nodes
invitee.created webhook to n8n. The workflow grabs the invitee's email, looks them up in HubSpot, refreshes their Apollo data if last enriched >30 days ago, scrapes their company homepage for fresh signal, and asks Claude Sonnet 4.5 to write a 250-word brief. The brief is queued via the n8n Wait node to fire 30 minutes before the meeting starts, posted as a Slack DM to the AE.
## Why this matters now
Two changes made this practical in 2026. Calendly's v2 API stabilised the invitee.created webhook payload with consistent event URIs (their April 2026 update fixed a long-standing bug where the event time was sometimes UTC and sometimes invitee-local). And n8n's Wait node v1.7 (March 2026) added an absolute-time mode — you can now say "wait until 30 min before this exact ISO timestamp" without writing a sleep-and-poll loop.
Before this, our AEs used a manual Notion template, copy-pasted LinkedIn snippets, and skipped prep when the day got busy. The skip rate was ~38% based on a 4-week audit. After 8 weeks on the automated brief, "I went in cold" complaints dropped to zero.
## The 4-step workflow
Step 1: Calendly webhook
invitee.created fires. We extract email, event URI, scheduled start time, and which AE owns the meeting.
Step 2: Multi-source enrich
HubSpot lookup, Apollo refresh (if stale), and a homepage scrape to surface today's hero copy.
Step 3: Claude brief generation
Sonnet 4.5 writes a 250-word, opinionated, AE-tone brief with one suggested opener.
Step 4: Wait then Slack DM
n8n Wait until T-30. Slack DM to the AE with the brief and a one-click "open HubSpot" link.
- n8n v1.121+ (the Wait node absolute-time mode shipped in v1.118)
- Calendly Standard plan or higher (webhooks require it as of April 2026)
- HubSpot account with API access, contacts module populated
- Apollo.io account, same setup as Post 2
- Anthropic API key with Claude Sonnet 4.5
- Slack bot token with chat:write and users:read.email scopes
- A mapping table (Google Sheet or n8n Set node) of AE name → Slack user ID
https://n8n.softechinfra.com/webhook/calendly-booked. The signing key Calendly issues becomes your CALENDLY_WEBHOOK_SECRET env var.
Webhook node config:
json
{
"parameters": {
"httpMethod": "POST",
"path": "calendly-booked",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "calendly-webhook-001",
"name": "Calendly Booked",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300]
}Calendly-Webhook-Signature looks like t=1716200400,v1=abcdef....
javascript
const crypto = require('crypto');
const secret = $env.CALENDLY_WEBHOOK_SECRET;
const header = $json.headers['calendly-webhook-signature'] || '';
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', secret)
.update(parts.t + '.' + $json.body)
.digest('hex');
if (expected !== parts.v1) throw new Error('Invalid Calendly signature');
const payload = JSON.parse($json.body);
const event = payload.payload;
return {
json: {
invitee_email: event.email,
invitee_name: event.name,
event_uri: event.event,
invitee_uri: event.uri,
meeting_start_iso: event.scheduled_event?.start_time,
meeting_end_iso: event.scheduled_event?.end_time,
ae_email: event.scheduled_event?.event_memberships?.[0]?.user_email,
answers: event.questions_and_answers || []
}
};event.questions_and_answers array contains anything the prospect typed in the Calendly form (company name, "what are you hoping to discuss"). This is gold for the Claude prompt later.
## Step 2 — Multi-source enrich
Three parallel HTTP calls then a Merge node:
a) HubSpot lookup by email — the native HubSpot node, operation get by email. Pulls the contact record, the company association, all past form-fill data, and any icp_score / icp_tier from Post 2's workflow.
b) Apollo refresh — same call as Post 2, but only fired if apollo_match_at is older than 30 days. A Set node decides:
javascript
const lastMatched = $('Get HubSpot Contact').first().json.apollo_match_at;
const stale = !lastMatched || (Date.now() - new Date(lastMatched).getTime()) > 30 86400 1000;
return { json: { stale } };website property), with a Set node trimming to the first 4,000 chars of visible text. We use a lightweight HTML-to-text Code node:
javascript
const html = $json.body;
const text = html
.replace(/