# Sync Zoom webinar answers, polls and attendance to HubSpot contacts

HubSpot's native Zoom integration brings over name and email only. Custom registration answers, poll answers and attendance per session of a recurring webinar stay in Zoom, so people export a CSV and import it by hand (10-15 minutes per webinar, per the HubSpot Community thread).

## How the fix works

`zoom_hubspot_sync.py` runs after a webinar. For one occurrence it reads three things from the Zoom API: registrants (with their custom question answers), the participant report, and the poll results. It builds one set of properties per email address and writes them onto contacts that already exist in HubSpot.

- Attendance minutes are summed per email, so someone who joined twice counts once.
- Attendees with no email (signed-out guests) are never guessed from display names. They go to `out/unmatched.csv` for review.
- By default nothing is written to HubSpot. It only writes CSV files. You add `--apply` to write, and `--create-missing` to also create new contacts.

Properties written to each contact:

| Property | Content |
|---|---|
| `zoom_last_webinar_id` | webinar ID you passed in |
| `zoom_last_webinar_date` | date of the occurrence (YYYY-MM-DD) |
| `zoom_attended` | true or false |
| `zoom_minutes_attended` | total minutes across all joins, rounded |
| `zoom_q_<question>` | one per registration question |
| `zoom_poll_<question>` | one per poll question (several answers joined with ` | `) |

## Setup

1. In the Zoom App Marketplace, create a **Server-to-Server OAuth** app. Copy the Account ID, Client ID and Client Secret. Add read scopes that cover webinar registrants, past webinars/polls and webinar reports. Scope names change, so check the current Zoom docs for the exact names.
2. In HubSpot, go to Settings > Integrations > Private Apps and create an app with the contacts read and write scopes. Copy its access token.
3. Set the secrets as environment variables (never put them in the script):
   ```
   export ZOOM_ACCOUNT_ID=...
   export ZOOM_CLIENT_ID=...
   export ZOOM_CLIENT_SECRET=...
   export HUBSPOT_TOKEN=...
   ```
4. Run a dry run for one finished webinar. Find the webinar ID in Zoom. To see the past occurrences, leave out `--occurrence-uuid`; the script lists them (newest first) and exits:
   ```
   python3 zoom_hubspot_sync.py --webinar-id 123456789
   python3 zoom_hubspot_sync.py --webinar-id 123456789 --occurrence-uuid 'THE_UUID'
   ```
   `--latest` picks the newest past occurrence. UUIDs that start with `/` or contain `//` are double URL-encoded by the script, as the Zoom docs require. Quote the UUID in your shell.
5. Open `out/properties_to_create.csv`. In HubSpot, go to Settings > Properties > Contact properties and create each property with the same internal name and type (the `type` and `field_type` columns use HubSpot's API names; for example `bool` with `booleancheckbox` is a single checkbox). A write fails if a property does not exist.
6. Open `out/planned_updates.csv` and check a few rows against Zoom. Open `out/unmatched.csv` for attendees without an email.
7. Run again with `--apply`. The script looks up each email in HubSpot and updates the contacts it finds. Emails not found in HubSpot are skipped and added to `unmatched.csv`.
8. Only if you want new contacts for people who are not in HubSpot yet, add `--create-missing`. It then upserts by email instead.

Optional flags: `--out-dir`, `--zoom-base`, `--zoom-oauth-url`, `--hubspot-base`. The base URLs are flags so you can change them if the API versions or hosts change.

## Test it offline

```
python3 test_fix.py
```

This runs the script on the fake data in `sample/` (example.com addresses, "Sample Co"). It needs no credentials and no network. It checks that a person who joined twice is summed (35 minutes), that the attendee without an email lands in `unmatched.csv`, that a dry run makes no HubSpot call, that `--apply` does a read then an update, and that `--create-missing` does an upsert. Expected last line: `PASS: all checks`.

You can also run the script by hand: `python3 zoom_hubspot_sync.py --webinar-id 123 --mock sample --occurrence-uuid '/Sample//Uuid+One=='`.

Mock mode swaps only the function that makes HTTP requests for a lookup in the `sample/` files. All other code (paging, UUID encoding, matching, CSV output, HubSpot batching) is the same code live mode uses. The real Zoom and HubSpot calls have not been tested; see `TEST_RESULTS.md`.

## Limits and what it does not cover

- **Not tested live.** The endpoints are the ones named in Zoom's and HubSpot's public API docs as I know them, but check the current docs for paths, response fields and scopes before relying on it. Run the dry run on one past webinar first.
- **Latest only.** These are contact properties, so each run overwrites the previous webinar's values. If you need history per session, store one record per session (a custom object or a note) instead. This script does not do that.
- **Email is the only match key.** No name matching.
- **Registrants are for the whole webinar.** The registrant list is not filtered by occurrence, so for a recurring webinar `zoom_q_*` values come from the registration, while attendance and polls come from the occurrence you pass in. People who registered but did not attend that occurrence get `zoom_attended=false`.
- **Properties must exist first.** The script does not create them.
- **Question slugs.** Property names come from the question text, cut to 40 characters. Two questions that become the same name would overwrite each other. If you change question wording, a new property is needed.
- **Poll shape.** The script expects poll answers grouped per participant with an email. Polls answered anonymously are listed in `unmatched.csv`.
- **Blank answers are skipped**, so an empty answer will not erase an old value.
- **Zoom Events** is a separate product and is not covered.
- Large lists are sent in batches of 100 contacts. Rate limits are only handled by a short retry on HTTP 429.
