Zoom Integration
Receive and route Zoom webhooks for meetings, webinars and cloud recordings.
Setup
1. Create a Source in Hookbase
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Zoom Production",
"slug": "zoom",
"provider": "zoom",
"signingSecret": "your-zoom-secret-token"
}'Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/zoom2. Create the Zoom Webhook
Zoom webhooks belong to an app you create in the Zoom Marketplace, not to your account as a whole.
- Create or open the app that will send events
- Add event subscriptions and set the event notification endpoint to your Hookbase ingest URL
- Copy the app's Secret Token and store it on your Hookbase source as
signingSecret - Select the events you want the app to send
The Secret Token is the key for every signature Zoom sends from that app. It is not the app's Client Secret, and the two are not interchangeable.
Warning
Hookbase's ingest endpoint does not answer Zoom's URL validation challenge. Before Zoom will send
events to an endpoint, it posts an endpoint.url_validation event carrying a plainToken and accepts
the endpoint only if the response body echoes that token back together with a keyed hash of it. There
is no handler for that event anywhere in the ingest path — a successful ingest responds with
Hookbase's own {"success": true, "eventId": "...", "deliveries": N} and nothing else — so pointing
Zoom's notification endpoint straight at a Hookbase ingest URL fails validation and no events are ever
sent.
The challenge has to be answered by an endpoint you control. Validate there, then switch the app's notification endpoint to your Hookbase ingest URL, or keep your own endpoint in front and forward requests to Hookbase. Everything below applies either way: Hookbase verifies Zoom's signature from the same Secret Token exactly as Zoom computes it.
See Zoom's webhook documentation for the validation response shape and the event catalog.
3. Create Destinations and Routes
# Create a destination
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Meeting Analytics", "slug": "zoom-analytics", "url": "https://api.myapp.com/webhooks/zoom"}'
# Create a route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Zoom to Analytics", "sourceId": "src_...", "destinationId": "dst_..."}'A route carries one destination. To fan the same events out to two services, create two routes over the same source.
Signature Verification
Zoom signs a timestamp and the body together, not the body alone, and sends the two parts in two separate headers:
X-Zm-Request-Timestamp: 1700000000
X-Zm-Signature: v0=b7e1c9a3d5f70286c4a6e8d0f2b4a6c8e0d2f4a6b8c0d2e4f6a8b0c2d4e6f8a0The signed string is v0: then the timestamp, then :, then the raw request body — literally
v0:{timestamp}:{body}. Hookbase takes the hex HMAC-SHA256 of that string, keyed with the Secret
Token, and compares it against the header value with the v0= prefix removed. The prefix is required:
a value arriving in X-Zm-Signature without it is treated as malformed rather than compared.
The Secret Token is used exactly as you stored it — its UTF-8 bytes are the HMAC key. Nothing is stripped from it and nothing is base64-decoded first.
Hookbase verifies this automatically once the source has both fields set:
{
"provider": "zoom",
"signingSecret": "your-zoom-secret-token"
}Info
The timestamp is checked, not just signed. Hookbase rejects a Zoom signature whose
X-Zm-Request-Timestamp is more than 300 seconds away from the receiving clock. Because the
timestamp is part of the signed string, it also cannot be edited on the way through: changing the
header changes the expected digest, so a request with a fresh timestamp and an old signature fails on
the comparison instead.
Warning
This is the same scheme as Slack, under different header names. Slack and Zoom agree on all of it
— hex HMAC-SHA256, the v0= prefix, the v0:<timestamp>:<body> signing string and the 300-second
tolerance. What differs is only where the two values live: Slack sends X-Slack-Signature and
X-Slack-Request-Timestamp, Zoom sends X-Zm-Signature and X-Zm-Request-Timestamp.
That makes the schemes look interchangeable, and they are not. Hookbase reads the signature header its
scheme names and no other, so a source left on provider: "slack" receiving Zoom traffic finds no
X-Slack-Signature and marks every event unverified — with a valid signature sitting in the request
the whole time. If you copied a working Slack source to set this one up, change the provider.
Once you have confirmed events are arriving with signature_valid: true, set
rejectInvalidSignatures: true on the source to have unverified requests refused with 401 instead
of stored.
Common Events
| Event | Description |
|---|---|
meeting.started | A meeting started |
meeting.ended | A meeting ended |
meeting.created | A meeting was scheduled |
meeting.participant_joined | A participant joined a meeting |
meeting.participant_left | A participant left a meeting |
recording.completed | A cloud recording completed |
webinar.started | A webinar started |
webinar.ended | A webinar ended |
Meeting Started
{
"event": "meeting.started",
"event_ts": 1678901234000,
"payload": {
"account_id": "acc_abc123",
"object": {
"id": 12345678901,
"uuid": "mtg-uuid-123",
"topic": "Weekly Standup",
"type": 2,
"start_time": "2024-01-15T10:30:00Z",
"duration": 30,
"host_id": "host_abc123",
"timezone": "America/New_York"
}
}
}Recording Completed
{
"event": "recording.completed",
"event_ts": 1678902000000,
"payload": {
"account_id": "acc_abc123",
"object": {
"id": 12345678901,
"topic": "Weekly Standup",
"recording_files": [
{
"file_type": "MP4",
"file_size": 52428800,
"recording_start": "2024-01-15T10:30:00Z",
"recording_end": "2024-01-15T11:00:00Z",
"download_url": "https://zoom.us/rec/download/..."
}
]
}
}
}Transform Examples
A javascript transform receives the parsed payload and returns the body Hookbase delivers.
Meeting Row for a Warehouse
function transform(payload) {
const o = (payload.payload && payload.payload.object) || {};
return {
event: payload.event,
account_id: payload.payload ? payload.payload.account_id : null,
meeting_id: o.id || null,
meeting_uuid: o.uuid || null,
topic: o.topic || null,
host_id: o.host_id || null,
start_time: o.start_time || null,
duration_minutes: o.duration || null,
// event_ts is milliseconds, unlike the seconds in X-Zm-Request-Timestamp.
occurred_at: payload.event_ts ? new Date(payload.event_ts).toISOString() : null
};
}Slack Notification When a Recording Is Ready
function transform(payload) {
const o = (payload.payload && payload.payload.object) || {};
const files = o.recording_files || [];
const totalBytes = files.reduce((sum, f) => sum + (f.file_size || 0), 0);
return {
text: `Zoom recording ready: ${o.topic || o.id}`,
blocks: [
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Topic:*\n${o.topic || "unknown"}` },
{ type: "mrkdwn", text: `*Meeting:*\n${o.id || "unknown"}` },
{ type: "mrkdwn", text: `*Files:*\n${files.length}` },
{ type: "mrkdwn", text: `*Size:*\n${Math.round(totalBytes / 1048576)} MB` }
]
}
]
};
}Filter Examples
Filter conditions read dotted paths out of the payload. logic must be AND or OR.
Recordings Only
{
"name": "Recordings Only",
"logic": "AND",
"conditions": [
{
"field": "event",
"operator": "equals",
"value": "recording.completed"
}
]
}Meeting Lifecycle Without Participant Churn
{
"name": "Meeting Lifecycle",
"logic": "OR",
"conditions": [
{
"field": "event",
"operator": "equals",
"value": "meeting.started"
},
{
"field": "event",
"operator": "equals",
"value": "meeting.ended"
}
]
}One Account
{
"name": "Primary Account",
"logic": "AND",
"conditions": [
{
"field": "payload.account_id",
"operator": "equals",
"value": "acc_abc123"
}
]
}Headers
| Header | Description |
|---|---|
X-Zm-Signature | v0= followed by the hex HMAC-SHA256 of v0:{timestamp}:{body} |
X-Zm-Request-Timestamp | Unix seconds. Part of the signed string, and checked against a 300-second tolerance |
Hookbase does not forward these headers to your destination. A delivery is a fresh request:
Hookbase sets Content-Type, User-Agent: Hookbase/1.0, X-Delivery-ID and X-Event-ID, then
adds the headers and auth you configured on the destination. Nothing Zoom sent reaches your
handler as a header — read what you need out of the body, or set it on the destination yourself.
Of the incoming headers, only content-type, user-agent, x-github-event, x-gitlab-event and
stripe-signature are stored on the event, so those are the only ones visible later in the
dashboard or the API.
Troubleshooting
Signature Verification Failed
- Confirm the source's
provideriszoom. A source left oncustomreadsX-Signature,X-Webhook-SignatureandX-Hub-Signature-256, and one left onslackreadsX-Slack-Signature— Zoom sends none of those - Confirm
signingSecretis the app's Secret Token, not its Client Secret, and that it is stored verbatim with no surrounding whitespace - Check the clock: the signed timestamp must be within 300 seconds. A delivery replayed from a capture does not verify even though the signature is genuine
- Each Zoom app has its own Secret Token. If several apps point at the same source, only the one whose token you stored verifies
No Events Arriving
- Zoom will not send events to an endpoint that has not passed URL validation. See the warning above — the Hookbase ingest endpoint cannot pass it on its own
- Check the app's event subscriptions actually include the events you are waiting for
- Confirm the notification endpoint matches your ingest URL exactly, including the org and source slugs
Duplicate Events
Hookbase has no provider event-id extractor for Zoom, so the default auto dedup strategy falls back
to hashing the payload. Two byte-identical deliveries inside the window collapse into one; two
deliveries differing only in event_ts do not. If something in front of Hookbase adds an idempotency
header, set dedupCustomHeader to its name — X-Idempotency-Key and Idempotency-Key are already
read on every source without configuration.