Files
calendar/docs/api-reference/v2/oauth.mdx
T
Lauris SkraucisGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
bd25cba89c refactor: OAuth 2.0 endpoints (#27442)
* refactor: combine exchange and refresh into token endpoint

* refactor: controller error handling

* refactor: use snake_case

* refactor: use snake_case

* refactor: use snake case

* refactor: token endpoint accepts application/x-www-form-urlencoded

* refactor: token endpoint accepts application/x-www-form-urlencoded

* refactor: flat token response data

* refactor: error structure

* refactor: client_id in the body

* fix: address Cubic AI review feedback on OAuth2 endpoints

- Fix getClient endpoint to use proper REST API error format instead of OAuth token error format (confidence 9/10)
- Add missing space after comma in error format string in token.input.pipe.ts (confidence 9/10)
- Support both camelCase and snake_case inputs in authorize endpoint for backward compatibility (confidence 10/10)
- Restore legacy /exchange and /refresh endpoints alongside new /token endpoint for backward compatibility (confidence 10/10)
- Add OAuth2TokensResponseDto for legacy endpoint wrapped responses
- Add OAuth2LegacyExchangeInput and OAuth2LegacyRefreshInput for legacy endpoints

Co-Authored-By: unknown <>

* fix: address additional Cubic AI feedback on OAuth2 endpoints

- Log errors when status code >= 500 in handleClientError (confidence 9/10)
- Add Cache-Control: no-store and Pragma: no-cache headers to legacy /exchange and /refresh endpoints (confidence 9/10)

Co-Authored-By: unknown <>

* docs

* Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints"

This reverts commit 39cc4aa3ebb9e59a171541d7010398425995ed89.

* Revert "fix: address Cubic AI review feedback on OAuth2 endpoints"

This reverts commit 97bf593186db04c0859f9ca30950c9e3e524019d.

* docs

* fix: address Cubic AI review feedback on OAuth2 endpoints

- Fix getClient to use handleClientError instead of handleTokenError (confidence 10)
- Restore legacy /exchange and /refresh endpoints for backward compatibility (confidence 9)
- Fix RFC 6749 error format: use human-readable messages in error_description (confidence 9)
- Fix errorDescription in OAuthService to use OAUTH_ERROR_REASONS mapping (confidence 9)

Co-Authored-By: unknown <>

* fix: address additional Cubic AI feedback on OAuth2 endpoints

- Fix security issue: Replace 'CALENDSO_ENCRYPTION_KEY is not set' with generic 'Internal server configuration error' message (confidence 10/10)
- Fix backward compatibility: Create OAuth2LegacyTokensDto with camelCase properties for legacy /exchange and /refresh endpoints (confidence 9/10)
- Skipped: RFC 6749 error field issue (confidence 8/10, below threshold)

Co-Authored-By: unknown <>

* e2e

* Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints"

This reverts commit a080e93f07aaf5a7dcf81fe605012cb7ebcdc192.

* Revert "fix: address Cubic AI review feedback on OAuth2 endpoints"

This reverts commit 04986a16c981521ca97069152457bf521a9ee45f.

* fix: re-apply Cubic AI review feedback on OAuth2 endpoints

- Restore OAuth2LegacyExchangeInput and OAuth2LegacyRefreshInput classes
- Restore legacy /exchange and /refresh endpoints in OAuth2Controller
- Restore OAuth2LegacyTokensDto and OAuth2TokensResponseDto classes
- Restore OAUTH_ERROR_DESCRIPTIONS mapping in oauth2-error.service.ts
- Restore OAUTH_ERROR_REASONS lookup in OAuthService.ts mapErrorToOAuthError
- Fix encryption_key_missing error to not expose internal env var name

Addresses Cubic AI feedback with confidence >= 9/10:
- Comment 32 (9/10): Legacy endpoints and input classes
- Comment 34 (9/10): Error description mapping in OAuthService
- Comment 35 (10/10): OAUTH_ERROR_DESCRIPTIONS in error service

Skipped (confidence < 9/10):
- Comment 33 (8/10): getClient handleTokenError vs handleClientError

Co-Authored-By: unknown <>

* Revert "fix: re-apply Cubic AI review feedback on OAuth2 endpoints"

This reverts commit 416bef9c931d9a7ed78c65a70a3425550d61b151.

* delete unused file

* fix: e2e tests

* address cubic review

* fix: address Cubic AI review feedback on OAuth2 exception filter

- Fix header case sensitivity: use lowercase 'x-request-id' instead of 'X-Request-Id' since Express lowercases all request headers
- Redact request body in error logs to prevent exposing sensitive OAuth2 credentials like client_secret, password, and refresh_token

Co-Authored-By: unknown <>

* docs: api v2 oauth controller docs

* chore: remove authorize endpoint

* refactor: remove scope from docs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-05 10:16:15 +02:00

452 lines
13 KiB
Plaintext

---
title: "OAuth"
description: "Authorize apps with Cal.com accounts using OAuth"
---
As an example, you can view our OAuth flow in action on Zapier. Try to connect your Cal.com account [here](https://zapier.com/apps/calcom/integrations). To enable OAuth in one of your apps, you will need a Client ID, Client Secret, Authorization URL, Access Token Request URL, and Refresh Token Request URL.
<Frame>
![](/images/oauth-zapier.png)
</Frame>
#### Get your OAuth "Continue with [Cal.com](http://Cal.com)" Badge
- https://app.cal.com/continue-with-calcom-coss-ui.svg
- https://app.cal.com/continue-with-calcom-dark-rounded.svg
- https://app.cal.com/continue-with-calcom-dark-squared.svg
- https://app.cal.com/continue-with-calcom-light-rounded.svg
- https://app.cal.com/continue-with-calcom-light-squared.svg
- https://app.cal.com/continue-with-calcom-neutral-rounded.svg
- https://app.cal.com/continue-with-calcom-light-squared.svg
## 1. OAuth Client Credentials
You can create an OAuth client via the following page https://app.cal.com/settings/developer/oauth. The OAuth client will be in a "pending" state
and not yet ready to use.
An admin from Cal.com will then review your OAuth client and you will receive an email if it was accepted or rejected. If it was accepted then your OAuth client
is ready to be used.
## 2. Authorize
To initiate the OAuth flow, direct users to the following authorization URL:
`https://app.cal.com/auth/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&state=YOUR_STATE`
**URL Parameters:**
| Parameter | Required | Description |
|-----------|----------|-------------|
| `client_id` | Yes | Your OAuth client ID |
| `redirect_uri` | Yes | Where users will be redirected after authorization. Must exactly match the registered redirect URI. |
| `state` | Recommended | A securely generated random string to mitigate CSRF attacks |
| `code_challenge` | For public clients | PKCE code challenge (S256 method) |
After users click **Allow**, they will be redirected to the `redirect_uri` with `code` (authorization code) and `state` as URL parameters:
```
https://your-app.com/callback?code=AUTHORIZATION_CODE&state=YOUR_STATE
```
#### Error Handling
Errors during the authorization step are displayed directly to the user on the Cal.com authorization page. Your application will not receive a JSON error response for these cases:
- **Client not found**: No OAuth client exists with the provided `client_id`.
- **Client not approved**: The OAuth client has not been approved by a Cal.com admin yet.
- **Mismatched redirect URI**: The `redirect_uri` does not match the one registered for the OAuth client.
If an error occurs after the client is validated (e.g., the user denies access or has insufficient permissions), the user is redirected to the `redirect_uri` with an error:
```
https://your-app.com/callback?error=access_denied&error_description=team_not_found_or_no_access&state=YOUR_STATE
```
## 3. Exchange Token
Exchange an authorization code for access and refresh tokens. The token endpoint also accepts `application/x-www-form-urlencoded` content type.
**Endpoint:** `POST https://api.cal.com/v2/auth/oauth2/token`
### 3.1 Confidential Clients
Confidential clients authenticate with a `client_secret`. All parameters are required:
| Parameter | Description |
|-----------|-------------|
| `client_id` | Your OAuth client ID |
| `client_secret` | Your OAuth client secret |
| `grant_type` | Must be `authorization_code` |
| `code` | The authorization code received in the redirect URI |
| `redirect_uri` | Must match the redirect URI used in the authorization request |
<Tabs>
<Tab title="cURL">
```bash
curl -X POST https://api.cal.com/v2/auth/oauth2/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "https://your-app.com/callback"
}'
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
grant_type: "authorization_code",
code: "AUTHORIZATION_CODE",
redirect_uri: "https://your-app.com/callback",
}),
});
const tokens = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.post(
"https://api.cal.com/v2/auth/oauth2/token",
{
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
grant_type: "authorization_code",
code: "AUTHORIZATION_CODE",
redirect_uri: "https://your-app.com/callback",
}
);
```
</Tab>
</Tabs>
### 3.2 Public Clients (PKCE)
Public clients (e.g. single-page apps, mobile apps) use PKCE instead of a `client_secret`. You must have sent a `code_challenge` during the authorization step. All parameters are required:
| Parameter | Description |
|-----------|-------------|
| `client_id` | Your OAuth client ID |
| `grant_type` | Must be `authorization_code` |
| `code` | The authorization code received in the redirect URI |
| `redirect_uri` | Must match the redirect URI used in the authorization request |
| `code_verifier` | The original PKCE code verifier used to generate the `code_challenge` |
<Tabs>
<Tab title="cURL">
```bash
curl -X POST https://api.cal.com/v2/auth/oauth2/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "https://your-app.com/callback",
"code_verifier": "YOUR_CODE_VERIFIER"
}'
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: "YOUR_CLIENT_ID",
grant_type: "authorization_code",
code: "AUTHORIZATION_CODE",
redirect_uri: "https://your-app.com/callback",
code_verifier: "YOUR_CODE_VERIFIER",
}),
});
const tokens = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.post(
"https://api.cal.com/v2/auth/oauth2/token",
{
client_id: "YOUR_CLIENT_ID",
grant_type: "authorization_code",
code: "AUTHORIZATION_CODE",
redirect_uri: "https://your-app.com/callback",
code_verifier: "YOUR_CODE_VERIFIER",
}
);
```
</Tab>
</Tabs>
#### Success Response (200)
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 1800
}
```
<Note>
Access tokens expire after 30 minutes (`expires_in: 1800`). Use the refresh token to obtain a new access token.
</Note>
#### Error Responses
Error responses include `error` and `error_description` fields.
<AccordionGroup>
<Accordion title="Invalid or expired authorization code (400)">
```json
{
"error": "invalid_grant",
"error_description": "code_invalid_or_expired"
}
```
The authorization code has already been used, has expired, or is invalid. Request a new authorization code.
</Accordion>
<Accordion title="Invalid client credentials (401)">
```json
{
"error": "invalid_client",
"error_description": "invalid_client_credentials"
}
```
The `client_secret` does not match the `client_id`. Verify your credentials.
</Accordion>
<Accordion title="Client not found (401)">
```json
{
"error": "invalid_client",
"error_description": "client_not_found"
}
```
No OAuth client exists with the provided `client_id`.
</Accordion>
<Accordion title="Missing client_id (400)">
```json
{
"error": "invalid_request",
"error_description": "client_id is required"
}
```
The `client_id` field is missing from the request body.
</Accordion>
<Accordion title="Invalid grant_type (400)">
```json
{
"error": "invalid_request",
"error_description": "grant_type must be 'authorization_code' or 'refresh_token'"
}
```
The `grant_type` field must be either `authorization_code` or `refresh_token`.
</Accordion>
</AccordionGroup>
## 4. Refresh Token
Refresh an expired access token using a refresh token.
**Endpoint:** `POST https://api.cal.com/v2/auth/oauth2/token`
### 4.1 Confidential Clients
Confidential clients authenticate with a `client_secret`. All parameters are required:
| Parameter | Description |
|-----------|-------------|
| `client_id` | Your OAuth client ID |
| `client_secret` | Your OAuth client secret |
| `grant_type` | Must be `refresh_token` |
| `refresh_token` | The refresh token received from a previous token response |
<Tabs>
<Tab title="cURL">
```bash
curl -X POST https://api.cal.com/v2/auth/oauth2/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"grant_type": "refresh_token",
"refresh_token": "YOUR_REFRESH_TOKEN"
}'
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
grant_type: "refresh_token",
refresh_token: "YOUR_REFRESH_TOKEN",
}),
});
const tokens = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.post(
"https://api.cal.com/v2/auth/oauth2/token",
{
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
grant_type: "refresh_token",
refresh_token: "YOUR_REFRESH_TOKEN",
}
);
```
</Tab>
</Tabs>
### 4.2 Public Clients
Public clients do not use a `client_secret`. All parameters are required:
| Parameter | Description |
|-----------|-------------|
| `client_id` | Your OAuth client ID |
| `grant_type` | Must be `refresh_token` |
| `refresh_token` | The refresh token received from a previous token response |
<Tabs>
<Tab title="cURL">
```bash
curl -X POST https://api.cal.com/v2/auth/oauth2/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"grant_type": "refresh_token",
"refresh_token": "YOUR_REFRESH_TOKEN"
}'
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: "YOUR_CLIENT_ID",
grant_type: "refresh_token",
refresh_token: "YOUR_REFRESH_TOKEN",
}),
});
const tokens = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.post(
"https://api.cal.com/v2/auth/oauth2/token",
{
client_id: "YOUR_CLIENT_ID",
grant_type: "refresh_token",
refresh_token: "YOUR_REFRESH_TOKEN",
}
);
```
</Tab>
</Tabs>
#### Success Response (200)
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 1800
}
```
#### Error Responses
<AccordionGroup>
<Accordion title="Invalid refresh token (400)">
```json
{
"error": "invalid_grant",
"error_description": "invalid_refresh_token"
}
```
The refresh token is invalid, expired, or malformed. The user must re-authorize.
</Accordion>
<Accordion title="Invalid client credentials (401)">
```json
{
"error": "invalid_client",
"error_description": "invalid_client_credentials"
}
```
The `client_secret` does not match the `client_id`.
</Accordion>
<Accordion title="Client not found (401)">
```json
{
"error": "invalid_client",
"error_description": "client_not_found"
}
```
No OAuth client exists with the provided `client_id`.
</Accordion>
</AccordionGroup>
## 5. Verify Access Token
To verify the correct setup and functionality of OAuth credentials, use the following endpoint:
**Endpoint:** `GET https://api.cal.com/v2/me`
<Tabs>
<Tab title="cURL">
```bash
curl -X GET https://api.cal.com/v2/me \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/me", {
headers: { Authorization: "Bearer YOUR_ACCESS_TOKEN" },
});
const user = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.get("https://api.cal.com/v2/me", {
headers: { Authorization: "Bearer YOUR_ACCESS_TOKEN" },
});
```
</Tab>
</Tabs>