# Load data

<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

## 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&mdash;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

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

### Python

```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)

```javascript
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

```json
{
    "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

```csv
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

```python
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

```javascript
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

```json
[
    {
        "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

```python
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

```javascript
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

```python
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

```javascript
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

```shell
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

```python
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

```javascript
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

```python
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

```javascript
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

```ini
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)**

```python
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:

```bash
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:

```python
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:

```python
# 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:

```python
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:

```python
#!/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**

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

---

### API reference summary

| Endpoint                                           | Method | Purpose                         | Rate Limit |
| -------------------------------------------------- | ------ | ------------------------------- | ---------- |
| `/zoom_events/events/{eventId}/ticket_types`       | GET    | List ticket types               | Standard   |
| `/zoom_events/events/{eventId}/tickets`            | POST   | Create tickets (max 30/request) | Standard   |
| `/zoom_events/events/{eventId}/tickets`            | GET    | List all tickets                | Standard   |
| `/zoom_events/events/{eventId}/tickets/{ticketId}` | GET    | Get ticket details              | Standard   |
| `/zoom_events/events/{eventId}/tickets/{ticketId}` | PATCH  | Update ticket                   | Standard   |
| `/zoom_events/events/{eventId}/tickets/{ticketId}` | DELETE | Delete ticket                   | Standard   |

**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
