Webinar - Wrong Rate Limit applied

Format Your New Topic as Follows:

API Endpoint(s) and/or Zoom API Event(s)
PUT /webinars/{webinarId}/registrants/status

Link the API endpoint(s) and/orZoom API Event(s) you’re working with to help give context.
→ /api/rest/reference/zoom-api/methods/#operation/webinarRegistrantStatus

Description
We organized a webinar for 1000 attendees.
We registered and approved attendees one by one by APIs call in a row.

  • POST /webinars/{webinarId}/registrants
  • PUT /webinars/{webinarId}/registrants/status

Only 10 attendees were able to join the webinar. We got 429 status code on all our queries after.

Error?
The full error message or issue you are running into, where applicable.

“You have exceeded the daily rate limit of (10) for webinar Update registrant's status API requests for the registrant”

It seems rate limit aren’t applied as describe. We made Unit Test to test it :

  • We create 30 attendees one by one running :
  • POST /webinars/{webinarId}/registrants
    then
  • PUT /webinars/{webinarId}/registrants/status
  • Always failed to the eleventh

Registrants (id and email) are always different, we are using the registrantId given from the POST response to the PUT query.

Means the limit describe isn’t working as expected : “Registrant status requests
10 requests per day (UTC) for the same registrant in the same meeting/webinar”

How To Reproduce
Steps to reproduce the behavior:
Here are our 2 tests files. (well… I had to copy, I can’t join file to the topic)
There is a fixture file containing 30 users with different emails, then this PHP File :

class ZoomApiTest extends KernelTestCase
{
    use IntegrationTestTrait;

    protected function setUp(): void
    {
        $kernel = self::bootKernel(['env' => 'test']);
        $this->app = new Application($kernel);
    }

    /**
     * Testing the Zoom rate-limit for registrant status request
     * https://developers.zoom.us/docs/api/rate-limits/
     */
    public function testAddingAttendeesToWebinar(): void
    {
        // Load the fixtures
        $this->loadFixture(__DIR__ . '/../../../features/bootstrap/fixtures/webinar/webinar_playwright_load_testing.yml');

        /** @var WebinarAttendeeRepository $attendeesRepository */
        $attendeesRepository = static::getContainer()->get(WebinarAttendeeRepository::class);

        /** @var WebinarRepository $webinarRepository */
        $webinarRepository = static::getContainer()->get(WebinarRepository::class);

        /** @var WebinarAttendee[] | null $attendees */
        $attendees = $attendeesRepository->findBy(['status' => WebinarAttendee::STATUS_ATTENDEE]);

        $webinar = $webinarRepository->find('webinar2');
        $this->assertInstanceOf(Webinar::class, $webinar);

        // 30 attendees
        $this->assertCount(30, $attendees);

        try {
            $webinarResponse = $this->createWebinar($webinar);
            $this->assertInstanceOf(CreateWebinarResponse::class, $webinarResponse);
            $this->assertNotEmpty($webinarResponse->getZoomWebinarId());
        } catch (ZoomException $e) {
            $this->fail($e->getMessage());
        }


        foreach ($attendees as $key => $attendee) {
            try {
                $response = $this->joinWebinarAsAttendee($attendee, $webinarResponse->getZoomWebinarId());
                $this->assertInstanceOf(JoinWebinarResponse::class, $response);
            } catch (ZoomException $e) {
                // Rate limit after 10 users
                // 10 requests per day (UTC) for the same registrant in the same meeting/webinar
                $this->assertStringContainsString('You have exceeded the daily rate limit of (10) for webinar Update registrant\'s status API requests for the registrant', $e->getMessage());
                $this->assertGreaterThanOrEqual(10, $key);
            }
        }
    }

    /**
     * @throws ZoomException
     * @throws DateInvalidTimeZoneException
     */
    public function createWebinar(Webinar $webinar): CreateWebinarResponse
    {
        $dateDiff = $webinar->getStartDate()->diff($webinar->getEndDate());
        $duration = $dateDiff->i + ($dateDiff->h * 60);

        $startDate = clone $webinar->getStartDate();
        $startDate->setTimezone(new DateTimeZone($webinar->getTimezone()));

        $response = $this->callApi(
            'POST',
            '/v2/users/me/webinars',
            [
                'agenda' => $webinar->getSummary(),
                "topic" => $webinar->getTitle(),
                "start_time" => $startDate->format('Y-m-d\TH:i:s'),
                "duration" => $duration,
                "timezone" => $webinar->getTimezone(),
                "type" => 5,
                // Manually approve
                "settings" => [
                    "approval_type" => 1,
                    "allow_multiple_devices" => false,
                    "auto_recording" => "cloud",
                    "panelists_invitation_email_notification" => false,
                    "registrants_email_notification" => false,
                    "practice_session" => true,
                    "panelists_video" => true,
                    "host_video" => true,
                    "question_and_answer" => [
                        "enabled" => true,
                        "allow_submit_questions" => true,
                        "allow_anonymous_questions" => false,
                        "answer_questions" => "all",
                        "attendees_can_comment" => true,
                        "attendees_can_upvote" => true,
                    ]
                ]
            ]
        );

        try {
            $data = $response->toArray();
            return new CreateWebinarResponse($data['id'], $data['start_url']);
        } catch (ExceptionInterface $e) {
            throw new ZoomException('An error occurred while creating webinar', 0, $e);
        }
    }

    public function joinWebinarAsAttendee(WebinarAttendee $attendee, string $webinarZoomId): JoinWebinarResponse
    {
        $response = $this->callApi(
            'POST',
            '/v2/webinars/' . $webinarZoomId . '/registrants',
            [
                'email' => $attendee->getUser()->getEmail(),
                'first_name' => $attendee->getUser()->getFirstName(),
                'last_name' => $attendee->getUser()->getLastName(),
                'address' => $attendee->getUser()->getAddress(),
                'language' => 'fr-FR'
            ]
        );
        $addRegistrantResponseData = $response->toArray();

        if ($response->getStatusCode() !== 201) {
            $message = $response->toArray(false)['message'] ?? 'An error occurred while adding registrant';
            throw new ZoomException($message);
        }

        $response = $this->callApi(
            'PUT',
            '/v2/webinars/' . $webinarZoomId . '/registrants/status',
            [
                'action' => 'approve',
                'registrants' => [
                    [
                        'id' => $addRegistrantResponseData['id'],
                        'email' => $attendee->getUser()->getEmail(),
                    ]
                ]
            ]
        );

        if ($response->getStatusCode() !== 204) {
            $message = $response->toArray(false)['message'] ?? 'An error occurred while adding registrant';
            throw new ZoomException($message);
        }
        return new JoinWebinarResponse($addRegistrantResponseData['join_url']);
    }

    private function callApi(string $httpVerb, string $uri, array $jsonPayload = null): ResponseInterface
    {
        $httpClient = HttpClient::createForBaseUri('https://zoom.us');

        $options = [
            'headers' => [
                "Authorization" => "Bearer {$this->getAccessToken()}"
            ]
        ];

        if ($jsonPayload) {
            $options['json'] = $jsonPayload;
        }

        try {
            return $httpClient->request($httpVerb, $uri, $options);
        } catch (ClientException|TransportExceptionInterface $e) {
            throw new ZoomException('An error occurred while calling Zoom API', 0, $e);
        }
    }

    private function getAccessToken(): string
    {
        $httpClient = HttpClient::createForBaseUri('https://zoom.us');
        $accountId = self::getContainer()->getParameter('zoomAccountId');
        $clientId = self::getContainer()->getParameter('zoomClientId');
        $clientSecret = self::getContainer()->getParameter('zoomClientSecret');

        // get access token from zoom
        $response = $httpClient->request('POST', '/oauth/token', [
            'query' => [
                'grant_type' => 'account_credentials',
                'account_id' => $accountId,
            ],
            'headers' => [
                'Authorization' => 'Basic ' . base64_encode("{$clientId}:{$clientSecret}")
            ]
        ]);


        return $response->toArray()['access_token'];
    }
}

Could you please, take a look, and explain.

Regards,

@ITNetwork ,

Since you mentioned it failed on the 11th attempt, it worth looking at the API which are limited to 10 per seconds or 10 per minute.

there is another specific restriction which you might be encountering. This will only happen if you are using the same exact registrant for the same exact webinar. There is a rate limit of 10 calls per day.

@chunsiong.zoom

We already have a look those limits before implementing.

/webinars/{webinarId}/registrants/status ENDPOINT as Meduim Rate Limit Label : 20/sec.

Anyways, the JSON response clearly indicates we are hitting your second link :

Registrant status requests

  • 10 requests per day (UTC) for the same registrant in the same meeting/webinar

But it’s not working properly. We are requesting registrant status on a DIFFERENT registrant on each call. Not the “SAME” as you can see on the bug reproduction. The rate limit shouldn’t be hit.

Could you please check around this point.

Regards,

@ITNetwork when you get the 429 error, could you check the response header for a web tracking ID?

it should be something like zm-tracking-id

@chunsiong.zoom
We just rerun the test to get one :

x-zm-trackingid: v=2.0;clid=aw1;rid=WEB_4d5d38054f69b3577ce44203be60a72d

@chunsiong.zoom Not sure you get the tag, because I tagged you on edit. Sorry.

@ITNetwork I’ve managed to replicate this, and will be investigating.

Internal reference usage: ZSEE-151972

@chunsiong.zoom
Great ! Thanks.
Looking forward to your investigation,
Regards,

@ITNetwork when approving the user using the registrants/status endpoint, you will need to put in the email and id. Do note that the id in this instance is the registrant id instead of the webinar id.

It should work once you input the correct registrant id.

@chunsiong.zoom

Indeed, we are sending webinarId instead of registrantId.
Thanks for your assistant.

Yet, sending the webinarId, approve correctly the registrant (by the other fields I guess : email) because we got 10 registrants approved by passing wrong “id”. We are sending a wrong data, it works as expected, the API doesn’t throw any issue.
There is no check on your part to confirm “registrantId” has been added with the “email” used on endpoint : post /webinars/{webinarId}/registrants (which return the registrantId)
Is it normal ?

Regards,