calcom/cal.diy · error · BadRequestException

${responseBody.error}

Error message

${responseBody.error}

What it means

Thrown in connectZoomApp in two spots when Zoom returns a body containing an `error` field. First: after a non-200 status where the JSON parse succeeds, errorMessage is set to responseBody.error and thrown as BadRequestException (HTTP 400). Second: after a 200 response, `if (responseBody.error)` re-checks and throws. The surfaced message is whatever Zoom put in `error` (e.g. invalid_grant, invalid_client).

Source

Thrown at apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts:89

        },
      }
    );

    if (result.status !== 200) {
      let errorMessage = "Something is wrong with Zoom API";
      try {
        const responseBody = await result.json();
        errorMessage = responseBody.error;
      } catch (e) {
        errorMessage = await result.clone().text();
      }
      throw new BadRequestException(errorMessage);
    }

    const responseBody = await result.json();

    if (responseBody.error) {
      throw new BadRequestException(responseBody.error);
    }

    responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
    delete responseBody.expires_in;

    if (!userId) {
      throw new UnauthorizedException("Invalid Access token.");
    }

    const existingCredentialZoomVideo = teamId
      ? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(ZOOM_TYPE, teamId)
      : await this.credentialsRepository.findAllCredentialsByTypeAndUserId(ZOOM_TYPE, userId);

    const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id);
    if (credentialIdsToDelete.length > 0) {
      teamId
        ? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
        : await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);

View on GitHub (pinned to 176037d0af)

Solutions

  1. Read the forwarded error value: invalid_grant -> re-auth (new code); invalid_client -> fix app keys.
  2. Restart the OAuth flow from generateZoomAuthUrl to get a fresh code and complete it in a single pass.
  3. Ensure redirect_uri in the token request matches the Zoom Marketplace allow-list exactly.
Defensive patterns

Strategy: try-catch

Try / catch

try {
  await zoomService.connectZoomApp(state, code, userId, teamId);
} catch (e) {
  if (e instanceof BadRequestException) {
    const msg = String(e.message);
    if (msg.includes('invalid_grant')) {
      // restart OAuth: code reused/expired
    } else if (msg.includes('invalid_client')) {
      // fix Zoom app keys
    }
  }
  throw e;
}

Prevention

When it happens

Trigger: Reusing an authorization code, expired code, wrong redirect_uri, or bad client credentials in the Zoom token exchange. Zoom responds with an `error` field and this code forwards it verbatim.

Common situations: User reloads the OAuth callback URL (code already consumed); redirect_uri mismatch with Zoom Marketplace app config; client_secret rotated on Zoom but not in app keys; clock skew causing code expiry.

Related errors


AI-assisted analysis of calcom/cal.diy@176037d0af (2026-08-12). Data as JSON: /api/errors/7cce9ded77c4a557. Report an issue: GitHub.