Load data

What you will build

An automated workflow that imports 500 VIP customers from your Salesforce CRM and pre-registers them for your annual virtual multi-session event, complete with custom join links and automated email confirmations.

Time required

30—45 minutes

Use case

You are hosting a high-value virtual multi-session event and need to automatically register your top 500 customers from Salesforce. Rather than manually entering each attendee or asking them to fill out a registration form, you will use the Webinars Plus & Events API to programmatically create tickets (pre-register attendees) and send them personalized join links.


Prerequisites

Before you start, ensure you have:

  • Published event Your event must be published (not draft) - attempting to pre-register for draft events returns error 26501.
  • Ticket Type created At least one ticket type configured during event setup.
  • API credentials OAuth access token with zoom_events:write:ticket or zoom_events:write:ticket:admin scope.
  • CRM xxport CSV or JSON export from your CRM with attendee data including email, first_name, and last_name.

What you will learn

  1. How to retrieve event ticket types from the Webinars Plus & Events API.
  2. How to prepare bulk attendee data from CRM exports.
  3. How to batch-process 500+ registrations (30 at a time per API limit).
  4. How to handle partial failures and retry logic.
  5. How to verify successful registrations and extract join URLs.

Step 1: Retrieve your event's Ticket Type ID

Every attendee needs to be assigned a ticket type (e.g., "VIP Pass", "General Admission"). First, fetch the available ticket types for your event. If you do not provide a ticket type ID when registering an attendee, the system will use the event's default ticket type, when available.

cURL

curl -X GET "https://api.zoom.us/v2/zoom_events/events/kNqLPC6hSFiZ9NpgjA549w/ticket_types" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Python

import requests
event_id = "kNqLPC6hSFiZ9NpgjA549w"
access_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
response = requests.get(
    f"https://api.zoom.us/v2/zoom_events/events/{event_id}/ticket_types",
    headers={"Authorization": f"Bearer {access_token}"}
)
response.raise_for_status()  # Check for HTTP errors
ticket_types = response.json()
print(f"Found {ticket_types['total_records']} ticket type(s)")

JavaScript (Node.js)

const axios = require("axios");
const eventId = "kNqLPC6hSFiZ9NpgjA549w";
const accessToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...";
async function getTicketTypes() {
    try {
        const response = await axios.get(
            `https://api.zoom.us/v2/zoom_events/events/${eventId}/ticket_types`,
            {
                headers: { Authorization: `Bearer ${accessToken}` },
            },
        );
        console.log(`Found ${response.data.total_records} ticket type(s)`);
        return response.data.ticket_types;
    } catch (error) {
        console.error(
            "Failed to get ticket types:",
            error.response?.data || error.message,
        );
        throw error;
    }
}

Expected response

{
    "total_records": 2,
    "ticket_types": [
        {
            "ticket_type_id": "1",
            "name": "VIP Pass",
            "price": 0,
            "quantity": 500,
            "description": "Exclusive access for VIP customers"
        },
        {
            "ticket_type_id": "2",
            "name": "General Admission",
            "price": 0,
            "quantity": 1000
        }
    ]
}

Save the ticket_type_id - in this example, we'll use "1" (VIP Pass) for our 500 customers.


Step 2: Prepare your CRM data

Export your 500 VIP customers from Salesforce and format them for the Webinars Plus & Events API.

Example CSV export from Salesforce

ContactId,Email,FirstName,LastName,Company,Title
003xx000001ABC1,john.smith@acme.com,John,Smith,Acme Corp,CTO
003xx000001ABC2,sarah.jones@techco.com,Sarah,Jones,TechCo Inc,VP Engineering
003xx000001ABC3,michael.brown@startup.io,Michael,Brown,Startup.io,Founder
003xx000001ABC4,emily.davis@enterprise.com,Emily,Davis,Enterprise LLC,Director

Transform to API-ready format

Convert your CSV into the format required by the API.

Python Script

import csv
import json
def load_crm_data(csv_file):
    """Load CRM data and transform to API format"""
    attendees = []
    with open(csv_file, 'r') as file:
        reader = csv.DictReader(file)
        for row in reader:
            attendees.append({
                "email": row['Email'],
                "first_name": row['FirstName'],
                "last_name": row['LastName'],
                "ticket_type_id": "1",  # VIP Pass
                "send_notification": True,  # Send Zoom confirmation email
                "external_ticket_id": row['ContactId'],  # Track back to Salesforce
                "custom_questions": [
                    {
                        "title": "custom_q1",  # If you have custom questions
                        "answer": row['Company']
                    }
                ]
            })
    return attendees
# Load data
vip_customers = load_crm_data('salesforce_export.csv')
print(f"Loaded {len(vip_customers)} VIP customers for registration")

JavaScript Script

const fs = require("fs");
const csv = require("csv-parser");
async function loadCrmData(csvFile) {
    const attendees = [];
    return new Promise((resolve, reject) => {
        fs.createReadStream(csvFile)
            .pipe(csv())
            .on("data", (row) => {
                attendees.push({
                    email: row.Email,
                    first_name: row.FirstName,
                    last_name: row.LastName,
                    ticket_type_id: "1", // VIP Pass
                    send_notification: true,
                    external_ticket_id: row.ContactId,
                    custom_questions: [
                        {
                            title: "custom_q1",
                            answer: row.Company,
                        },
                    ],
                });
            })
            .on("end", () => {
                console.log(`Loaded ${attendees.length} VIP customers`);
                resolve(attendees);
            })
            .on("error", reject);
    });
}

Sample Transformed Data

[
    {
        "email": "john.smith@acme.com",
        "first_name": "John",
        "last_name": "Smith",
        "ticket_type_id": "1",
        "send_notification": true,
        "external_ticket_id": "003xx000001ABC1",
        "custom_questions": [{ "title": "custom_q1", "answer": "Acme Corp" }]
    },
    {
        "email": "sarah.jones@techco.com",
        "first_name": "Sarah",
        "last_name": "Jones",
        "ticket_type_id": "1",
        "send_notification": true,
        "external_ticket_id": "003xx000001ABC2",
        "custom_questions": [{ "title": "custom_q1", "answer": "TechCo Inc" }]
    }
]

Step 3: Validate data before upload

Critical Step: Validate your data to avoid API errors during bulk upload.

Python Validation Script

def validate_attendees(attendees):
    """Validate attendee data before API submission"""
    errors = []
    seen_emails = set()
    for i, attendee in enumerate(attendees):
        # Check required fields
        if not attendee.get('email') or '@' not in attendee['email']:
            errors.append(f"Row {i+1}: Invalid email '{attendee.get('email')}'")
        if not attendee.get('first_name'):
            errors.append(f"Row {i+1}: Missing first_name")
        if not attendee.get('last_name'):
            errors.append(f"Row {i+1}: Missing last_name")
        # Check for duplicate emails
        email_lower = attendee.get('email', '').lower()
        if email_lower in seen_emails:
            errors.append(f"Row {i+1}: Duplicate email '{attendee['email']}'")
        seen_emails.add(email_lower)
    return errors
# Validate before processing
validation_errors = validate_attendees(vip_customers)
if validation_errors:
    print("Validation failed:")
    for error in validation_errors[:10]:  # Show first 10 errors
        print(f"  - {error}")
    exit(1)
else:
    print(" Validation passed - ready to upload")

JavaScript validation script

function validateAttendees(attendees) {
    const errors = [];
    const seenEmails = new Set();
    attendees.forEach((attendee, i) => {
        // Check required fields
        if (!attendee.email || !attendee.email.includes("@")) {
            errors.push(`Row ${i + 1}: Invalid email '${attendee.email}'`);
        }
        if (!attendee.first_name) {
            errors.push(`Row ${i + 1}: Missing first_name`);
        }
        if (!attendee.last_name) {
            errors.push(`Row ${i + 1}: Missing last_name`);
        }
        // Check for duplicates
        const emailLower = (attendee.email || "").toLowerCase();
        if (seenEmails.has(emailLower)) {
            errors.push(`Row ${i + 1}: Duplicate email '${attendee.email}'`);
        }
        seenEmails.add(emailLower);
    });
    return errors;
}
// Validate
const validationErrors = validateAttendees(vipCustomers);
if (validationErrors.length > 0) {
    console.error("Validation failed:");
    validationErrors.slice(0, 10).forEach((err) => console.error(`  - ${err}`));
    process.exit(1);
} else {
    console.log(" Validation passed - ready to upload");
}

Step 4: Batch Upload 500 Attendees (30 per Request)

The Webinars Plus & Events API allows up to 30 tickets per request. To register 500 attendees, you will make 17 API calls (16 batches of 30 + 1 batch of 20).

Python Implementation with Progress Tracking

import requests
import time
from math import ceil
def create_tickets_batch(event_id, access_token, tickets):
    """Create a batch of tickets via API"""
    url = f"https://api.zoom.us/v2/zoom_events/events/{event_id}/tickets"
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json"
    }
    response = requests.post(url, headers=headers, json={"tickets": tickets})
    response.raise_for_status()  # Check for HTTP errors
    return response.json()
def bulk_register_attendees(event_id, access_token, attendees, batch_size=30):
    """Register attendees in batches with progress tracking"""
    total = len(attendees)
    num_batches = ceil(total / batch_size)
    all_tickets = []
    all_errors = []
    print(f"Starting bulk registration: {total} attendees in {num_batches} batches\n")
    for batch_num in range(num_batches):
        start_idx = batch_num * batch_size
        end_idx = min(start_idx + batch_size, total)
        batch = attendees[start_idx:end_idx]
        print(f"Batch {batch_num + 1}/{num_batches}: Processing {len(batch)} attendees...")
        try:
            result = create_tickets_batch(event_id, access_token, batch)
            # Track successful tickets
            if 'tickets' in result:
                all_tickets.extend(result['tickets'])
                print(f"   Successfully registered {len(result['tickets'])} attendees")
            # Track errors
            if 'errors' in result and result['errors']:
                all_errors.extend(result['errors'])
                print(f"   {len(result['errors'])} failures")
            # Rate limiting: wait 1 second between batches
            if batch_num < num_batches - 1:
                time.sleep(1)
        except Exception as e:
            print(f" Batch failed with error: {str(e)}")
            all_errors.extend([{"email": a["email"], "error": str(e)} for a in batch])
    print(f"\n{'='*60}")
    print(f"Bulk Registration Complete")
    print(f"{'='*60}")
    print(f"Total Processed: {total}")
    print(f"Successful: {len(all_tickets)}")
    print(f"Failed: {len(all_errors)}")
    print(f"Success Rate: {len(all_tickets)/total*100:.1f}%")
    return all_tickets, all_errors
# Execute bulk registration
event_id = "kNqLPC6hSFiZ9NpgjA549w"
access_token = "your_access_token_here"
successful_tickets, failed_tickets = bulk_register_attendees(
    event_id,
    access_token,
    vip_customers
)

JavaScript Implementation with Progress Tracking

const axios = require("axios");
async function createTicketsBatch(eventId, accessToken, tickets) {
    try {
        const response = await axios.post(
            `https://api.zoom.us/v2/zoom_events/events/${eventId}/tickets`,
            { tickets },
            {
                headers: {
                    Authorization: `Bearer ${accessToken}`,
                    "Content-Type": "application/json",
                },
            },
        );
        return response.data;
    } catch (error) {
        console.error(
            "Failed to create tickets batch:",
            error.response?.data || error.message,
        );
        throw error;
    }
}
async function bulkRegisterAttendees(
    eventId,
    accessToken,
    attendees,
    batchSize = 30,
) {
    const total = attendees.length;
    const numBatches = Math.ceil(total / batchSize);
    const allTickets = [];
    const allErrors = [];
    console.log(
        `Starting bulk registration: ${total} attendees in ${numBatches} batches\n`,
    );
    for (let batchNum = 0; batchNum < numBatches; batchNum++) {
        const startIdx = batchNum * batchSize;
        const endIdx = Math.min(startIdx + batchSize, total);
        const batch = attendees.slice(startIdx, endIdx);
        console.log(
            `Batch ${batchNum + 1}/${numBatches}: Processing ${batch.length} attendees...`,
        );
        try {
            const result = await createTicketsBatch(
                eventId,
                accessToken,
                batch,
            );
            // Track successful tickets
            if (result.tickets) {
                allTickets.push(...result.tickets);
                console.log(
                    `   Successfully registered ${result.tickets.length} attendees`,
                );
            }
            // Track errors
            if (result.errors && result.errors.length > 0) {
                allErrors.push(...result.errors);
                console.log(`  ✗ ${result.errors.length} failures`);
            }
            // Rate limiting: wait 1 second between batches
            if (batchNum < numBatches - 1) {
                await new Promise((resolve) => setTimeout(resolve, 1000));
            }
        } catch (error) {
            const errorMsg =
                error.response?.data || error.message || "Unknown error";
            console.log(`  ✗ Batch failed with error: ${errorMsg}`);
            allErrors.push(
                ...batch.map((a) => ({ email: a.email, error: errorMsg })),
            );
        }
    }
    console.log(`\n${"=".repeat(60)}`);
    console.log("Bulk Registration Complete");
    console.log("=".repeat(60));
    console.log(`Total Processed: ${total}`);
    console.log(`Successful: ${allTickets.length}`);
    console.log(`Failed: ${allErrors.length}`);
    console.log(
        `Success Rate: ${((allTickets.length / total) * 100).toFixed(1)}%`,
    );
    return { tickets: allTickets, errors: allErrors };
}
// Execute
const eventId = "kNqLPC6hSFiZ9NpgjA549w";
const accessToken = "your_access_token_here";
(async () => {
    const result = await bulkRegisterAttendees(
        eventId,
        accessToken,
        vipCustomers,
    );
    console.log(`\nFirst 3 registered attendees:`);
    result.tickets.slice(0, 3).forEach((t) => {
        console.log(`  ${t.email}: ${t.event_join_link || "N/A"}`);
    });
})();

Expected Console Output

Starting bulk registration: 500 attendees in 17 batches
Batch 1/17: Processing 30 attendees...
  Successfully registered 30 attendees
Batch 2/17: Processing 30 attendees...
  Successfully registered 30 attendees
Batch 3/17: Processing 30 attendees...
  Successfully registered 29 attendees
  1 failures
...
Batch 17/17: Processing 20 attendees...
  Successfully registered 20 attendees
============================================================
Bulk Registration Complete
============================================================
Total Processed: 500
Successful: 497
Failed: 3
Success Rate: 99.4%

Step 5: Handle Failures and Retry

Some registrations may fail (duplicate emails, invalid data, rate limits). Implement retry logic for failed attendees.

Python Retry Logic

import time
def retry_failed_registrations(event_id, access_token, failed_attendees, max_retries=3):
    """Retry failed registrations with exponential backoff"""
    retry_queue = failed_attendees.copy()
    final_failures = []
    for attempt in range(1, max_retries + 1):
        if not retry_queue:
            break
        print(f"\nRetry Attempt {attempt}/{max_retries}: {len(retry_queue)} attendees")
        successful = []
        still_failing = []
        for attendee in retry_queue:
            try:
                # Retry individually for better error isolation
                result = create_tickets_batch(event_id, access_token, [attendee])
                if result.get('tickets'):
                    successful.append(result['tickets'][0])
                    print(f"  Retry success: {attendee['email']}")
                else:
                    still_failing.append(attendee)
                    print(f"  Still failing: {attendee['email']} - {result.get('errors', [{}])[0].get('message', 'Unknown error')}")
                time.sleep(0.5)  # Rate limit protection
            except Exception as e:
                still_failing.append(attendee)
                print(f"  Error: {attendee['email']} - {str(e)}")
        retry_queue = still_failing
        # Exponential backoff between retry attempts
        if retry_queue and attempt < max_retries:
            backoff = 2 ** attempt
            print(f"Waiting {backoff} seconds before next retry...")
            time.sleep(backoff)
    print(f"\nRetry Summary:")
    print(f"  Recovered: {len(successful)}")
    print(f"  Permanent Failures: {len(retry_queue)}")
    return successful, retry_queue
# Retry failed attendees from initial upload
if failed_tickets:
    print(f"\nRetrying {len(failed_tickets)} failed registrations...")
    recovered, permanent_failures = retry_failed_registrations(
        event_id,
        access_token,
        [a for a in vip_customers if a['email'] in [f.get('email') for f in failed_tickets]]
    )
    successful_tickets.extend(recovered)
    if permanent_failures:
        print(f"\n⚠️  {len(permanent_failures)} attendees could not be registered:")
        for failure in permanent_failures[:5]:
            print(f"  - {failure['email']}")

JavaScript Retry Logic

async function retryFailedRegistrations(
    eventId,
    accessToken,
    failedAttendees,
    maxRetries = 3,
) {
    let retryQueue = [...failedAttendees];
    const successful = [];
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
        if (retryQueue.length === 0) break;
        console.log(
            `\nRetry Attempt ${attempt}/${maxRetries}: ${retryQueue.length} attendees`,
        );
        const stillFailing = [];
        for (const attendee of retryQueue) {
            try {
                const result = await createTicketsBatch(eventId, accessToken, [
                    attendee,
                ]);
                if (result.tickets && result.tickets.length > 0) {
                    successful.push(result.tickets[0]);
                    console.log(`  Retry success: ${attendee.email}`);
                } else {
                    stillFailing.push(attendee);
                    const errorMsg =
                        result.errors?.[0]?.message || "Unknown error";
                    console.log(
                        `  Still failing: ${attendee.email} - ${errorMsg}`,
                    );
                }
                await new Promise((resolve) => setTimeout(resolve, 500));
            } catch (error) {
                stillFailing.push(attendee);
                const errorMsg =
                    error.response?.data || error.message || "Unknown error";
                console.log(`  Error: ${attendee.email} - ${errorMsg}`);
            }
        }
        retryQueue = stillFailing;
        if (retryQueue.length > 0 && attempt < maxRetries) {
            const backoff = Math.pow(2, attempt);
            console.log(`Waiting ${backoff} seconds before next retry...`);
            await new Promise((resolve) => setTimeout(resolve, backoff * 1000));
        }
    }
    console.log(`\nRetry Summary:`);
    console.log(`  Recovered: ${successful.length}`);
    console.log(`  Permanent Failures: ${retryQueue.length}`);
    return { recovered: successful, failures: retryQueue };
}

Step 6: Verify Registrations and Extract Join URLs

After bulk registration, verify the results and extract join URLs for your records.

Python Verification Script

def save_registration_results(tickets, output_file='registration_results.csv'):
    """Save successful registrations to CSV"""
    import csv
    with open(output_file, 'w', newline='') as file:
        writer = csv.DictWriter(file, fieldnames=[
            'ticket_id', 'email', 'first_name', 'last_name',
            'event_join_link', 'external_ticket_id', 'status'
        ])
        writer.writeheader()
        for ticket in tickets:
            writer.writerow({
                'ticket_id': ticket.get('ticket_id'),
                'email': ticket.get('email'),
                'first_name': ticket.get('first_name'),
                'last_name': ticket.get('last_name'),
                'event_join_link': ticket.get('event_join_link'),
                'external_ticket_id': ticket.get('external_ticket_id'),
                'status': ticket.get('status', 'active')
            })
    print(f"\n✓ Registration results saved to {output_file}")
# Save results
save_registration_results(successful_tickets)
# Show sample results
print("\nSample Join URLs (first 5):")
for ticket in successful_tickets[:5]:
    print(f"  {ticket['email']}: {ticket.get('event_join_link', 'N/A')}")

JavaScript Verification Script

const fs = require("fs");
function saveRegistrationResults(
    tickets,
    outputFile = "registration_results.csv",
) {
    const csvHeader =
        "ticket_id,email,first_name,last_name,event_join_link,external_ticket_id,status\n";
    const csvRows = tickets
        .map((ticket) => {
            return [
                ticket.ticket_id,
                ticket.email,
                ticket.first_name,
                ticket.last_name,
                ticket.event_join_link || "",
                ticket.external_ticket_id || "",
                ticket.status || "active",
            ].join(",");
        })
        .join("\n");
    fs.writeFileSync(outputFile, csvHeader + csvRows);
    console.log(`\n Registration results saved to ${outputFile}`);
}
// Save results
saveRegistrationResults(result.tickets);
// Show sample results
console.log("\nSample Join URLs (first 5):");
result.tickets.slice(0, 5).forEach((ticket) => {
    console.log(`  ${ticket.email}: ${ticket.event_join_link || "N/A"}`);
});

Sample Output: registration_results.csv

ticket_id,email,first_name,last_name,event_join_link,external_ticket_id,status
abc123def456,john.smith@acme.com,John,Smith,https://events.zoom.us/j/abc123def456,003xx000001ABC1,active
xyz789uvw012,sarah.jones@techco.com,Sarah,Jones,https://events.zoom.us/j/xyz789uvw012,003xx000001ABC2,active
mno345pqr678,michael.brown@startup.io,Michael,Brown,https://events.zoom.us/j/mno345pqr678,003xx000001ABC3,active

Step 7: Verify in Webinars Plus & Events Dashboard

Log into your Webinars Plus & Events account and verify the registrations:

  1. Navigate to your event → People → Attendees.
  2. Check total registrations: Should show ~500 attendees.
  3. Verify attendee details: Check a few records match your CRM data.
  4. Confirm email delivery: Check "Communications" → "Emails" for send stats.

API verification (optional)

def verify_registrations(event_id, access_token):
    """Verify total registration count via API"""
    url = f"https://api.zoom.us/v2/zoom_events/events/{event_id}/tickets"
    headers = {"Authorization": f"Bearer {access_token}"}
    response = requests.get(url, headers=headers)
    response.raise_for_status()  # Check for HTTP errors
    data = response.json()
    print(f"\nAPI Verification:")
    print(f"  Total Registrations: {data.get('total_records', 0)}")
    print(f"  Active Tickets: {len([t for t in data.get('tickets', [])])}")
verify_registrations(event_id, access_token)

Common Error scenarios and solutions

Error 26501: "Event is not published yet"

Cause

Attempting to pre-register attendees for a draft event.

Solution

Publish your event first:

curl -X POST "https://api.zoom.us/v2/zoom_events/events/{eventId}/event_actions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer {ACCESS_TOKEN}" \
  -d '{"operation": "publish"}'

Error 26502 or 2007: "Invalid ticket_type_id"

Cause

The ticket type ID doesn't exist for this event.

Solution

Re-fetch ticket types and verify the ID:

response = requests.get(f"https://api.zoom.us/v2/zoom_events/events/{event_id}/ticket_types")
response.raise_for_status()  # Check for HTTP errors
print(response.json())

Error 260503: "Non configured fields"

Cause

Using validation_level=strict and submitting fields not configured for the event.

Solution

Either:

  1. Remove unconfigured fields from your payload.
  2. Use validation_level=standard (default) which ignores extra fields.

Duplicate email error

Cause

Attempting to register the same email twice.

Solution

Check for existing registrations before bulk upload:

# Get existing tickets
response = requests.get(f"https://api.zoom.us/v2/zoom_events/events/{event_id}/tickets")
response.raise_for_status()  # Check for HTTP errors
existing = response.json()
existing_emails = {t['email'].lower() for t in existing.get('tickets', [])}
# Filter out duplicates
new_attendees = [a for a in vip_customers if a['email'].lower() not in existing_emails]

HTTP 429: Rate limit exceeded

Cause

Too many requests in a short period.

Solution

Implement exponential backoff:

import time
def api_call_with_retry(func, *args, max_retries=5):
    for attempt in range(max_retries):
        try:
            return func(*args)
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 429:
                wait_time = 2 ** attempt
                print(f"Rate limited. Waiting {wait_time}s...")
                time.sleep(wait_time)
            else:
                raise
    raise Exception("Max retries exceeded")

What you built

Congratulations! You have built a complete bulk registration system that:

  • Retrieves ticket types from your Webinars Plus & Events event.
  • Loads and validates 500 VIP customers from Salesforce.
  • Batch-processes registrations in chunks of 30 (API limit compliance).
  • Handles failures gracefully with retry logic and error tracking.
  • Extracts join URLs for each attendee.
  • Exports results to CSV for record-keeping.
  • Tracks back to CRM using external_ticket_id for future reference.

Key outcomes

  • Time saved: ~20 hours of manual data entry eliminated
  • Accuracy: 99%+ success rate with automated validation
  • Attendee experience: Immediate email confirmations with personalized join links
  • CRM integration: Bidirectional tracking with external_ticket_id
  • Scalability: Can handle 1,000+ attendees with same workflow

Next steps

Enhance your workflow

  1. Custom email integration: Set send_notification: false and send branded emails from your marketing platform.
  2. Real-time sync: Build a webhook listener to sync Salesforce with Zoom Webinars Plus & Events automatically.
  3. Session-specific registration: For recurring events, add session_id to register attendees for specific dates.
  4. Custom questions: Capture additional data by configuring event questions and including answers in the payload.

Complete code example

Here is a production-ready Python script combining all steps:

#!/usr/bin/env python3
"""
Bulk Pre-Register Attendees from CSV to Zoom Webinars Plus & Events
Usage: python bulk_register.py --event-id EVENT_ID --csv salesforce_export.csv
"""
import argparse
import csv
import requests
import time
from math import ceil
class ZoomEventsBulkRegistration:
    def __init__(self, event_id, access_token):
        self.event_id = event_id
        self.access_token = access_token
        self.base_url = "https://api.zoom.us/v2/zoom_events/events"
    def get_ticket_type_id(self):
        """Fetch the first available ticket type"""
        response = requests.get(
            f"{self.base_url}/{self.event_id}/ticket_types",
            headers={"Authorization": f"Bearer {self.access_token}"}
        )
        response.raise_for_status()
        ticket_types = response.json().get('ticket_types', [])
        if not ticket_types:
            raise ValueError("No ticket types found for this event")
        return ticket_types[0]['ticket_type_id']
    def load_csv(self, csv_file, ticket_type_id):
        """Load attendees from CSV"""
        attendees = []
        with open(csv_file, 'r') as file:
            reader = csv.DictReader(file)
            for row in reader:
                attendees.append({
                    "email": row['Email'],
                    "first_name": row['FirstName'],
                    "last_name": row['LastName'],
                    "ticket_type_id": ticket_type_id,
                    "send_notification": True,
                    "external_ticket_id": row.get('ContactId', '')
                })
        return attendees
    def validate_attendees(self, attendees):
        """Validate attendee data"""
        errors = []
        seen = set()
        for i, a in enumerate(attendees):
            if not a.get('email') or '@' not in a['email']:
                errors.append(f"Row {i+1}: Invalid email")
            if not a.get('first_name') or not a.get('last_name'):
                errors.append(f"Row {i+1}: Missing name")
            email_lower = a['email'].lower()
            if email_lower in seen:
                errors.append(f"Row {i+1}: Duplicate email {a['email']}")
            seen.add(email_lower)
        return errors
    def create_tickets_batch(self, tickets):
        """Create batch of tickets"""
        response = requests.post(
            f"{self.base_url}/{self.event_id}/tickets",
            headers={
                "Authorization": f"Bearer {self.access_token}",
                "Content-Type": "application/json"
            },
            json={"tickets": tickets}
        )
        response.raise_for_status()
        return response.json()
    def bulk_register(self, attendees, batch_size=30):
        """Register attendees in batches"""
        total = len(attendees)
        num_batches = ceil(total / batch_size)
        all_tickets = []
        all_errors = []
        print(f"Registering {total} attendees in {num_batches} batches\n")
        for i in range(num_batches):
            start = i * batch_size
            end = min(start + batch_size, total)
            batch = attendees[start:end]
            print(f"Batch {i+1}/{num_batches}: {len(batch)} attendees...", end=' ')
            try:
                result = self.create_tickets_batch(batch)
                all_tickets.extend(result.get('tickets', []))
                all_errors.extend(result.get('errors', []))
                print(f" {len(result.get('tickets', []))} succeeded")
                time.sleep(1)  # Rate limiting
            except Exception as e:
                print(f" Failed: {e}")
                all_errors.extend(batch)
        print(f"\nComplete: {len(all_tickets)} succeeded, {len(all_errors)} failed")
        return all_tickets, all_errors
    def save_results(self, tickets, filename='results.csv'):
        """Save results to CSV"""
        with open(filename, 'w', newline='') as file:
            writer = csv.DictWriter(file, fieldnames=[
                'ticket_id', 'email', 'first_name', 'last_name', 'event_join_link', 'status'
            ])
            writer.writeheader()
            for ticket in tickets:
                writer.writerow({
                    'ticket_id': ticket.get('ticket_id'),
                    'email': ticket.get('email'),
                    'first_name': ticket.get('first_name'),
                    'last_name': ticket.get('last_name'),
                    'event_join_link': ticket.get('event_join_link', ''),
                    'status': ticket.get('status', 'active')
                })
        print(f" Results saved to {filename}")
def main():
    parser = argparse.ArgumentParser(description='Bulk register attendees')
    parser.add_argument('--event-id', required=True, help='Zoom Events event ID')
    parser.add_argument('--csv', required=True, help='CSV file with attendees')
    parser.add_argument('--token', required=True, help='OAuth access token')
    args = parser.parse_args()
    # Initialize
    registrar = ZoomEventsBulkRegistration(args.event_id, args.token)
    # Get ticket type
    ticket_type_id = registrar.get_ticket_type_id()
    print(f"Using ticket type ID: {ticket_type_id}\n")
    # Load CSV
    attendees = registrar.load_csv(args.csv, ticket_type_id)
    print(f"Loaded {len(attendees)} attendees from CSV")
    # Validate
    errors = registrar.validate_attendees(attendees)
    if errors:
        print("Validation failed:")
        for error in errors[:10]:
            print(f"  {error}")
        return
    print(" Validation passed\n")
    # Register
    tickets, failures = registrar.bulk_register(attendees)
    # Save results
    if tickets:
        registrar.save_results(tickets)
if __name__ == '__main__':
    main()

Run the Script

python bulk_register.py \
  --event-id kNqLPC6hSFiZ9NpgjA549w \
  --csv salesforce_export.csv \
  --token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

API reference summary

EndpointMethodPurposeRate Limit
/zoom_events/events/{eventId}/ticket_typesGETList ticket typesStandard
/zoom_events/events/{eventId}/ticketsPOSTCreate tickets (max 30/request)Standard
/zoom_events/events/{eventId}/ticketsGETList all ticketsStandard
/zoom_events/events/{eventId}/tickets/{ticketId}GETGet ticket detailsStandard
/zoom_events/events/{eventId}/tickets/{ticketId}PATCHUpdate ticketStandard
/zoom_events/events/{eventId}/tickets/{ticketId}DELETEDelete ticketStandard

Required scopes

  • zoom_events:write:ticket or zoom_events:write:ticket:admin
  • zoom_events:read:list_ticket_types for listing ticket types

Frequently asked questions

Can I register more than 30 attendees at once?

No, the API limit is 30 tickets per request. Use batching (as shown in this tutorial).

Results if some attendees in a batch fail?

The API processes all 30, returns successful tickets in response.tickets and failures in response.errors. Partial success is normal.

Can I update a ticket after creation?

Yes, use PATCH /zoom_events/events/{eventId}/tickets/{ticketId} to update ticket details.

How do I delete a registration?

Use DELETE /zoom_events/events/{eventId}/tickets/{ticketId}. The attendee will receive a cancellation email.

Can I disable Zoom's confirmation emails?

Yes, set send_notification: false in the ticket payload. You are then responsible for sending custom emails with join URLs.

Do join URLs expire?

No, join URLs remain valid until the ticket is deleted or the event ends.

Can I register attendees for recurring events?

Yes, include session_ids in the payload to register for specific session(s).

How do I handle international characters (é, ñ, 中)?

The API accepts UTF-8. Ensure your CSV is UTF-8 encoded.


Troubleshooting checklist

  • Event is published (not draft)
  • Access token has correct scopes (zoom_events:write:ticket)
  • Ticket type ID is valid for this event
  • No duplicate emails in upload
  • Batch size ≤ 30 attendees
  • Rate limiting implemented (1s between batches)
  • Error handling catches HTTP 429 (rate limit)
  • Validation runs before upload