TL;DR
This tutorial shows how to connect Zoom Webinar events to your CRM using OpenClaw so that registrations create or update contacts, reminders go out on time, and no show follow ups happen automatically. The goal is a dependable workflow automation that runs without manual intervention. You will configure a Zoom webhook, map fields, upsert records, and add retries plus a dead letter queue for reliability. By the end, you will ship a production ready flow that a marketer can own without engineering tickets.
What you will build
You will build an end to end automation that:
- Creates or updates a CRM contact when a webinar registration is submitted.
- Schedules reminder messages with a join link 24 hours and 30 minutes before the live session.
- Writes attendance outcomes and triggers no show follow up.
- Adds basic observability, idempotency, and a dead letter queue for safe operations.
If you are new to ButterGrow or OpenClaw, skim the AI marketing automation features to understand what ButterGrow does before you start configuring connectors. See the AI marketing automation features at the feature set overview in what ButterGrow does at the feature set.
Prerequisites
- A Zoom account with Webinars enabled and permission to create App Marketplace Event Subscriptions.
- A CRM account. The examples use HubSpot, but any CRM with an upsert API works.
- An OpenClaw workspace with access to playbooks and secrets.
- API keys for your CRM stored in your OpenClaw secrets vault.
Architecture overview
At a high level, the flow looks like this:
- Zoom sends a webhook for a registration, attendee join, or attendee leave event to your OpenClaw ingress endpoint.
- The playbook validates the signature, normalizes payload fields, and computes a dedupe key using email plus the webinar id.
- The playbook upserts a CRM contact and logs a webinar activity record linked to the contact.
- Schedulers create two reminder jobs. A follow up job runs after the event to handle attended vs no show branches.
- Retries, idempotency keys, and a dead letter queue protect the flow from transient failures and duplicate deliveries.
Step by step
Step 1Prepare Zoom and CRM
Create a Zoom Server to Server app and add Event Subscriptions for webinar.registration_created, webinar.participant_joined, and webinar.participant_left. Copy the secret used to sign webhook requests. In your CRM, decide whether you will model a custom Activity or Engagement to record registrations and attendance. Add custom fields as needed for webinar id, webinar topic, and join URL so that reporting and follow ups are easy.
To see how OpenClaw structures reusable automation, read our overview of reusable OpenClaw playbooks and versioning, which explains how to ship templates and evolve them safely. See reusable OpenClaw playbooks and versioning.
Step 2Create the OpenClaw playbook
Define a playbook that exposes a public ingress endpoint, validates the Zoom signature, maps fields, and calls your CRM. The following example shows a compact but production friendly structure.
# file: playbooks/zoom_webinar_crm.yaml
apiVersion: openclaw/v1
kind: Playbook
metadata:
name: zoom-webinar-to-crm
description: Upsert CRM contacts and schedule reminders from Zoom Webinar events
spec:
ingress:
path: /ingress/zoom-webinar
method: POST
auth: none
secrets:
- name: ZOOM_WEBHOOK_SECRET
- name: CRM_API_KEY
steps:
- id: verify_signature
uses: std/http/verify-hmac
with:
header: x-zm-signature
secret: ${secrets.ZOOM_WEBHOOK_SECRET}
algorithm: sha256
- id: parse_event
uses: std/json/parse
with:
from: ${request.body}
- id: route
uses: std/control/switch
with:
on: ${steps.parse_event.data.event}
cases:
webinar.registration_created: registrant_flow
webinar.participant_joined: attendance_join
webinar.participant_left: attendance_leave
flows:
registrant_flow:
- id: normalize_registrant
uses: std/object/project
with:
input: ${steps.parse_event.data.payload}
pick:
email: registrant.email
first_name: registrant.first_name
last_name: registrant.last_name
webinar_id: object.id
webinar_topic: object.topic
start_time: object.start_time
join_url: registrant.join_url
- id: compute_keys
uses: std/object/extend
with:
input: ${steps.normalize_registrant.output}
add:
dedupe_key: ${hash.sha1(${input.email}+"|"+${input.webinar_id})}
start_time_rfc3339: ${time.to_rfc3339(${input.start_time})}
- id: upsert_contact
uses: connectors/hubspot/upsert-contact
with:
api_key: ${secrets.CRM_API_KEY}
email: ${steps.compute_keys.output.email}
properties:
firstname: ${steps.compute_keys.output.first_name}
lastname: ${steps.compute_keys.output.last_name}
zoom_webinar_id: ${steps.compute_keys.output.webinar_id}
zoom_webinar_topic: ${steps.compute_keys.output.webinar_topic}
zoom_join_url: ${steps.compute_keys.output.join_url}
zoom_dedupe_key: ${steps.compute_keys.output.dedupe_key}
- id: log_registration
uses: connectors/hubspot/create-engagement
with:
api_key: ${secrets.CRM_API_KEY}
type: NOTE
subject: Webinar Registration
body: ${"Registered for "+steps.compute_keys.output.webinar_topic}
associations:
contactId: ${steps.upsert_contact.output.id}
- id: schedule_24h
uses: std/schedule/at
with:
run_at: ${time.minus(${steps.compute_keys.output.start_time_rfc3339}, "24h")}
job: send_reminder
payload: ${steps.compute_keys.output}
- id: schedule_30m
uses: std/schedule/at
with:
run_at: ${time.minus(${steps.compute_keys.output.start_time_rfc3339}, "30m")}
job: send_reminder
payload: ${steps.compute_keys.output}
attendance_join:
- id: mark_attended
uses: connectors/hubspot/create-engagement
with:
api_key: ${secrets.CRM_API_KEY}
type: NOTE
subject: Webinar Join
body: ${"Joined webinar "+steps.parse_event.data.payload.object.topic}
attendance_leave:
- id: mark_left
uses: connectors/hubspot/create-engagement
with:
api_key: ${secrets.CRM_API_KEY}
type: NOTE
subject: Webinar Leave
body: ${"Left webinar "+steps.parse_event.data.payload.object.topic}
jobs:
send_reminder:
- id: render_email
uses: std/template/render
with:
template: |
Hi {{first_name}},
Your webinar "{{webinar_topic}}" starts soon.
Join here: {{join_url}}
data: ${job.payload}
- id: send_email
uses: connectors/email/send
with:
to: ${job.payload.email}
subject: Reminder for ${job.payload.webinar_topic}
body: ${steps.render_email.output}
reliability:
retries:
max_attempts: 5
backoff: exponential
idempotency:
key: ${steps.compute_keys.output.dedupe_key}
dlq:
topic: zoom-webinar-dlq
This pattern keeps ingress light and isolates side effects in clearly named steps. It also gives you one place to add retry and idempotency behavior that spans all cases.
Step 3Subscribe to Zoom webhooks
In the Zoom App Marketplace, add an Event Subscription that points to your public ingress URL. Select the events you need for registration, joins, and leaves. Save the verification token or secret and paste it into your OpenClaw secret named ZOOM_WEBHOOK_SECRET. Here is a realistic example payload you can use in development.
{
"event": "webinar.registration_created",
"event_ts": 1732819200000,
"payload": {
"account_id": "abc123",
"object": {
"id": 9876543210,
"topic": "Product Launch Q3",
"start_time": "2026-08-15T17:00:00Z"
},
"registrant": {
"email": "alex@example.com",
"first_name": "Alex",
"last_name": "Rivera",
"join_url": "https://zoom.us/w/9876543210?tk=..."
}
}
}
During local development, replay events with curl to hit your ingress endpoint. Replace the signature with a valid HMAC if your verify step is strict in non production environments.
curl -X POST \
-H "Content-Type: application/json" \
-H "x-zm-signature: dev-test-skip" \
--data @sample_registration.json \
https://your-runner.example.com/ingress/zoom-webinar
Step 4Map fields and upsert to the CRM
Create a clear mapping between the Zoom payload and your CRM properties. This lets marketing and sales agree on what data is captured and where it lands.
| Zoom source | CRM target | Notes |
|---|---|---|
registrant.email |
email |
Primary identifier used for upsert |
registrant.first_name |
firstname |
Ensure proper casing |
registrant.last_name |
lastname |
Ensure proper casing |
object.id |
zoom_webinar_id |
Used in dedupe key and reports |
object.topic |
zoom_webinar_topic |
Useful for personalization |
registrant.join_url |
zoom_join_url |
Used in reminders |
object.start_time |
webinar_start_time |
Normalize to RFC 3339 |
If you maintain mappings in code, keep them in one place. This simple JavaScript snippet shows the idea you can adapt inside a task that transforms payloads.
export function mapRegistrant(payload) {
const p = payload.payload;
return {
email: p.registrant.email.trim().toLowerCase(),
firstname: p.registrant.first_name?.trim() || "",
lastname: p.registrant.last_name?.trim() || "",
zoom_webinar_id: String(p.object.id),
zoom_webinar_topic: p.object.topic,
zoom_join_url: p.registrant.join_url,
webinar_start_time: new Date(p.object.start_time).toISOString()
};
}
Step 5Schedule reminders that respect time zones
Use the scheduler to create two reminders. Convert the event start time into the registrant's local time zone when you render the email so the copy is human friendly. If a time zone is missing, fall back to the webinar time and include the zone abbreviation in the subject.
jobs:
send_reminder:
- id: render
uses: std/template/render
with:
template: |
Hi {{first_name}}, your session starts at {{format_time(webinar_start_time, tz)}}.
Join here: {{join_url}}
data:
first_name: ${job.payload.first_name}
webinar_start_time: ${job.payload.start_time_rfc3339}
tz: ${lookup.timezone(${job.payload.email})}
- id: deliver
uses: connectors/email/send
with:
to: ${job.payload.email}
subject: Reminder for ${job.payload.webinar_topic}
body: ${steps.render.output}
Step 6Record attendance and run no show follow ups
When a participant joins, record an activity linked to the contact. After the webinar end time plus a short buffer, compute attended vs no show based on whether a join was seen. Send a light summary to those who attended and a recording plus next steps to those who missed it.
flows:
post_event:
- id: read_attendance
uses: state/get
with:
key: ${steps.compute_keys.output.dedupe_key}
- id: branch
uses: std/control/if
with:
condition: ${steps.read_attendance.output.join_seen == true}
then: attended
else: no_show
Step 7Add observability, idempotency, retries, and a DLQ
Production flows deserve guardrails. Instrument steps with structured logs. Use a stable idempotency key so that duplicate webhook deliveries do not create extra records. Add exponential backoff on all network calls. Route messages that still fail into a dead letter topic so you can inspect and replay. If these practices are new to you, study our primer on idempotency, retries and DLQs in OpenClaw for implementation patterns you can copy. See the idempotency, retries and DLQs guide at idempotency, retries and DLQs guide.
Step 8Test the end to end path
Run the playbook in a staging workspace. Register for a test webinar with multiple emails including a role account and a personal account. Confirm that contacts are created or updated, reminders arrive on schedule, and attendance is recorded. For quick feedback loops during build out, replay saved JSON payloads so you do not wait on live events.
curl -sSL https://raw.githubusercontent.com/your-org/fixtures/main/zoom/registration.json \
| curl -X POST -H "Content-Type: application/json" --data-binary @- \
https://staging.example.com/ingress/zoom-webinar
Step 9Ship with confidence
Before you point production webhooks at your ingress, enable audit logs and verify that all secrets are pulled from the vault. Take note of rate limits on your CRM connector and set budgets so spikes do not burn through quotas. Share the operational runbook with the marketer who owns the webinar program so they can see where to look when something behaves unexpectedly.
Common pitfalls and quick fixes
- Reminders arrive at the wrong local time. Normalize the timestamp and render it with the registrant time zone. Keep a fallback path for unknown zones.
- Duplicate contacts appear after a re registration. Use email plus webinar id as the dedupe key and make upserts idempotent across retries.
- Zoom calls the endpoint with a stale signature. Reject based on timestamp to prevent replay and alert the on call channel.
- The CRM API returns a 429 rate limit. Apply exponential backoff and use a priority queue if you have other jobs that must not starve. If you want strategy examples, browse more from the ButterGrow blog at more from the ButterGrow blog.
Variations and extensions
- Send SMS reminders to mobile numbers captured on the registration form if consent is present in your CRM.
- Add campaign parameters to the join link so you can attribute engagement by source. You can generate UTM parameters centrally and append them during template rendering.
- Push attendance outcomes to your advertising audiences for lookalike models on highly engaged registrants.
- Pipe a recording link and a short summary into your knowledge base so sales can reference it in follow ups.
If you want a quick orientation on what the product covers out of the box, the best starting point is answers to common questions at the FAQ.
ButterGrow runs OpenClaw for you and ships a library of connectors and templates so teams can ship this integration in hours, not weeks. If you want this running in production with guardrails and monitoring, you can get started in minutes by following the onboarding flow.
References
- Zoom webhooks documentation - Official documentation for subscribing to and validating Zoom webhook events.
- HubSpot Contacts API overview - Reference for creating or updating contacts and engagement records.
- UTM parameters - Overview of UTM campaign parameters and what each parameter means.
Frequently Asked Questions
How do I test Zoom webhook events for webinars without a public URL?+
Use a tunneling tool like ngrok to expose your local OpenClaw runner and point the Zoom Event Subscription to the tunnel URL. For repeatable tests, save real payloads from staging and replay them with curl to your playbook's ingress endpoint. Keep a small catalog of sample events so you can regression test changes quickly.
Which CRM objects should I upsert for webinar registrations and attendance?+
Create or update a Contact based on email, and optionally create a custom Activity or Engagement record labeled Webinar Registration or Webinar Attended. Link the activity to the Contact and include the webinar topic, scheduled time, and join URL. For HubSpot, use Contacts plus Engagements. For Salesforce, use Lead or Contact plus a custom Task or Event.
How do I prevent duplicate registrations from creating duplicate contacts?+
Hash a stable dedupe key like email plus webinar ID and store it in your CRM as a hidden property. In OpenClaw, make the upsert idempotent by reading the existing record first and updating only when fields change. Add a retry policy with exponential backoff and route exhausted attempts to a DLQ for inspection.
How can I secure the Zoom webhook so only Zoom can call my endpoint?+
Validate the Zoom signature header and timestamp before processing. Reject events that fail HMAC verification or that are older than a short window to avoid replay. Keep the shared secret in your secrets manager and never log it.
Can I personalize reminders by time zone and custom questions captured on the Zoom form?+
Yes. Normalize the registrant's time zone when available and render the time using that zone. Map Zoom custom question responses into CRM fields, then merge them into your email template. Always keep a default path for missing or invalid time zones to avoid failed sends.
What changes if I use Salesforce instead of HubSpot for this workflow?+
The data model and API endpoints change, but the playbook stays the same shape. Swap the CRM connector to Salesforce and map fields to Lead or Contact plus a Task or Campaign Member. Keep the same dedupe strategy and webhook validation.
Ready to try ButterGrow?
See how ButterGrow can supercharge your growth with a quick demo.
Book a Demo