Code 124 “Invalid access token” Error when trying to add registrant to meeting

I’ve read the articles I could find on this, and none of them appear to apply to my issue. I’m testing the workflow in Postman before doing any actual development work. I’ve got the following endpoints working fine:

  • Authentication for JWT
  • Create a new meeting
  • List upcoming meetings
  • Fetch meeting details
  • Update meeting details

What’s not working for me is adding a registrant to a meeting, which always gives me a 401 error {“code”:124,”message”:”invalid access token”}.

For the record, manual registration works fine, both through the registration link and via csv import. I’ve also created this on multiple test meetings, some created in the Zoom webUI and others via API call, all give the exact same result.

When I call for JWT, in addition to “access_token” I do get the following:

  "token_type": "bearer",

    "expires_in": 3600,

    "scope": "user:read:list_users:admin user:read:user:admin meeting:read:list_meetings:admin meeting:write:meeting:admin meeting:read:meeting:admin meeting:update:meeting:admin meeting:read:list_registrants:admin meeting:write:registrant:admin meeting:write:batch_registrants:admin meeting:read:registrant:admin meeting:update:registrant_status:admin meeting:read:livestream:admin meeting:read:list_polls:admin meeting:read:poll:admin meeting:read:invitation:admin meeting:write:invite_links:admin meeting:read:past_meeting:admin meeting:read:list_past_instances:admin meeting:read:list_past_participants:admin meeting:read:list_poll_results:admin meeting:read:participant:admin meeting:read:participant_feedback:admin meeting:read:participant_callout:admin meeting:read:alert:admin meeting:read:participant_sharing:admin meeting:read:device:admin meeting:read:risk_alert:admin meeting:read:chat_message:admin meeting:read:list_upcoming_meetings:admin meeting:read:past_qa:admin meeting:read:token:admin meeting:read:ai_companion:admin",

    "api_url": "https://api-us.zoom.us"

All my calls use the authentication flow in Postman to retrieve the token from a variable set by the auth call. So the registration call is using the exact same token as the others, so I know there is nothing wrong with it. After the registration fails, I try https://api.zoom.us/v2/meetings/{{MeetingID}} just to confirm that the token is still good.

Here is a curl export from the Postman call for registrations

curl --location 'https://api.zoom.us/v2/meetings/83927864687/registrants' \
--header 'Authorization: Bearer $TOKEN' \
--header 'Content-Type: application/json' \
--data-raw '{
    "first_name": "Test",
    "last_name": "User",
    "email": "testuser@example.com"
  }'

I’m on a Zoom Workspace Pro plan. Does anyone have any ideas where I’m going wrong or what is going on? BTW, I tried to open a ticket with support, who just responded, “Kindly buzz off and post here.”

1 Like

@siggib can you do a HTTP POST instead?

curl --location --request POST “https://api.zoom.us/v2/meetings/83927864687/registrants”
–header “Authorization: Bearer $TOKEN”
–header “Content-Type: application/json”
–data-raw ‘{
“first_name”: “Test”,
“last_name”: “User”,
“email”: “testuser@example.com”
}’

@siggib and get we confirm that the owner of the token is the host of the meeting as well?

Confirmed, the owner of the token is the meeting host. I am doing POST, not sure why Postman is dropping that from the curl command. Just tested it and POST is the only command it doesn’t show correctly in the curl output, sorry I misssed that when I pasted it in the topic.

I tried to attach a screenshot of postman but apperantly that isn’t allowed and neither is linking a screenshot

@siggib ,

I’m showing your what your full request should look like after after a user oauth redirect URL. Note: don’t use the token as it will not work on your account. This is just verbose example of how the request should look like.

Response from User OAuth Redirect URL

{“access_token”:“eyJzdiI6IjAwMDAwMiIsImFsZyI6IkhTNTEyIiwidiI6IjIuMCIsImtpZCI6IjBhOGY1MWI1LWJiYWItNDQyNS1hMDlhLWExMjQ0YWE3NzM4NyJ9.eyJhdWQiOiJodHRwczovL29hdXRoLnpvb20udXMiLCJ1aWQiOiJwSjhaR2tBRVFzU0w3eHlSaWFaU1ZBIiwidmVyIjoxMCwiYXVpZCI6ImU1OTAwYjk3MTYxYTYxNTM2NzhmNGU4NmUyYmFjNjEwNWEzYjU3MTFlMGRhNWQ3ZDkxNTgyZjdiZWU1YjQzZWEiLCJuYmYiOjE3NzMzMTg1ODEsImNvZGUiOiJoUlFWdk1YdVBXMXVvby1zRnZSUnB1bmtLMTUxbjNBY1EiLCJpc3MiOiJ6bTpjaWQ6aXNWVFh2WkFTYWVoU2RWRGpYMzh5USIsImdubyI6MCwiZXhwIjoxNzczMzIyMTgxLCJ0eXBlIjowLCJpYXQiOjE3NzMzMTg1ODEsImFpZCI6IjlzU19QQjJ5UjgyN25tdHNzZzl3Z1EifQ.TO0eBU6YXwkbU5Ew3piRgSae9ezZ7qyvNQsaokPC”,“token_type”:“bearer”,“refresh_token”:“eyJzdiI6IjAwMDAwMiIsImFsZyI6IkhTNTEyIiwidiI6IjIuMCIsImtpZCI6IjVhMmI1Y2RmLTc3ZjgtNGQwZS05MWFkLTljOWEyNmEwOGE5YiJ9.eyJhdWQiOiJodHRwczovL29hdXRoLnpvb20udXMiLCJ1aWQiOiJwSjhaR2tBRVFzU0w3eHlSaWFaU1ZBIiwidmVyIjoxMCwiYXVpZCI6ImU1OTAwYjk3MTYxYTYxNTM2NzhmNGU4NmUyYmFjNjEwNWEzYjU3MTFlMGRhNWQ3ZDkxNTgyZjdiZWU1YjQzZWEiLCJuYmYiOjE3NzMzMTg1ODEsImNvZGUiOiJoUlFWdk1YdVBXMXVvby1zRnZSUnB1bmtLMTUxbjNBY1EiLCJpc3MiOiJ6bTpjaWQ6aXNWVFh2WkFTYWVoU2RWRGpYMzh5USIsImdubyI6MCwiZXhwIjoxNzgxMDk0NTgxLCJ0eXBlIjoxLCJpYXQiOjE3NzMzMTg1ODEsImFpZCI6IjlzU19QQjJ5UjgyN25tdHNzZzl3Z1EifQ.jvuo1ueEMyN6zbcml6kNbwWMups5EhyWZS7pRS85nmeu5P7WO”,“expires_in”:3599,“scope”:… too long to paste here…,“api_url”:“``https://api-us.zoom.us``”}

How to craft an auth token using the access token. Do not include any text like “access_token” in the bearer token

curl --location --request POST “https://api-us.zoom.us/v2/meetings/83927864687/registrants” 
–header “Authorization: Bearer eyJzdiI6IjAwMDAwMiIsImFsZyI6IkhTNTEyIiwidiI6IjIuMCIsImtpZCI6IjBhOGY1MWI1LWJiYWItNDQyNS1hMDlhLWExMjQ0YWE3NzM4NyJ9.eyJhdWQiOiJodHRwczovL29hdXRoLnpvb20udXMiLCJ1aWQiOiJwSjhaR2tBRVFzU0w3eHlSaWFaU1ZBIiwidmVyIjoxMCwiYXVpZCI6ImU1OTAwYjk3MTYxYTYxNTM2NzhmNGU4NmUyYmFjNjEwNWEzYjU3MTFlMGRhNWQ3ZDkxNTgyZjdiZWU1YjQzZWEiLCJuYmYiOjE3NzMzMTg1ODEsImNvZGUiOiJoUlFWdk1YdVBXMXVvby1zRnZSUnB1bmtLMTUxbjNBY1EiLCJpc3MiOiJ6bTpjaWQ6aXNWVFh2WkFTYWVoU2RWRGpYMzh5USIsImdubyI6MCwiZXhwIjoxNzczMzIyMTgxLCJ0eXBlIjowLCJpYXQiOjE3NzMzMTg1ODEsImFpZCI6IjlzU19QQjJ5UjgyN25tdHNzZzl3Z1EifQ.TO0eBU6YXwkbU5Ew3piRgSae9ezZ7qyvNQsaokPC” 
–header “Content-Type: application/json” 
–data-raw ‘{
“first_name”: “Test”,
“last_name”: “User”,
“email”: “testuser@example.com”
}’


If this looks similar on your end on what you are doing, we will need to look at how you are getting the access token. Here’s a sample code in nodejs. request needs to be sent to https://zoom.us/oauth/token with grant_type as authorization_code

app.get(‘/redirecturlformsdkoauth’, async (req, res) => {
const code = req.query.code;

if (!code) {
  return res.status(400).json({ error: 'Missing code' });
}

try {
  const response = await axios.post(
    'https://zoom.us/oauth/token',
    new URLSearchParams({
      code,
      grant_type: 'authorization_code',
      redirect_uri: 'https://example.com/redirecturlformsdkoauth',
    }).toString(),
    {
      headers: {
        Authorization: `Basic ${Buffer.from(
          `${process.env.ZOOM_CLIENT_ID_NEW}:${process.env.ZOOM_CLIENT_SECRET_NEW}`
        ).toString('base64')}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    }
  );

  return res.status(200).json(response.data);
} catch (error) {
  console.error(error.response?.data || error.message);
  return res.status(500).json({ error: 'OAuth token exchange failed' });
}

});

Yes, mine is exactly like that. Here is the exact output for me

{
"access_token": "eyJzdiI6IjAwMDAwMiIsImFsZyI6IkhTNTEyIiwidiI6IjIuMCIsImtpZCI6IjQ3MGI3MDliLTQ5MWEtNGE1ZS1iOTE2LWU4ZmE5YzRiYzQ2NyJ9.eyJhdWQiOiJodHRwczovL29hdXRoLnpvb20udXMiLCJ1aWQiOiJudGZTLWlZVlJWbS1ybW9OeTVvZldBIiwidmVyIjoxMCwiYXVpZCI6IjYwYWRiMzIzZGFlMTgxNzQzOWE1NTEwMTEwNjUyZDk1NmZjZTdhOGFjNDFiNmY5MzQ3Yjk0ODQyNTUyMzE4NGYiLCJuYmYiOjE3NzMzMjU5NDAsImNvZGUiOiJ2d1BqTVdwX1JnaU5WeVdzd1o1Rjl3dHI0NVV4QjBnREEiLCJpc3MiOiJ6bTpjaWQ6U0lNUDNkdWVRMzZlVDJOdWFCY2NydyIsImdubyI6MCwiZXhwIjoxNzczMzI5NTQwLCJ0eXBlIjozLCJpYXQiOjE3NzMzMjU5NDAsImFpZCI6ImRLR1BsekQ0UWEtakJ6dElQT0dGYUEifQ.1_h0bWUs_SwUCQzDVAumBzQLA-tdfHP7A8VAMFxO9a4F7V94KtQ9himwx2r7U9SOGTbf8CBrbYMcugxKrYYQYQ",
"token_type": "bearer",
"expires_in": 3599,
"scope": "user:read:list_users:admin user:read:user:admin meeting:read:list_meetings:admin meeting:write:meeting:admin meeting:read:meeting:admin meeting:update:meeting:admin meeting:read:list_registrants:admin meeting:write:registrant:admin meeting:write:batch_registrants:admin meeting:read:registrant:admin meeting:update:registrant_status:admin meeting:read:livestream:admin meeting:read:list_polls:admin meeting:read:poll:admin meeting:read:invitation:admin meeting:write:invite_links:admin meeting:read:past_meeting:admin meeting:read:list_past_instances:admin meeting:read:list_past_participants:admin meeting:read:list_poll_results:admin meeting:read:participant:admin meeting:read:participant_feedback:admin meeting:read:participant_callout:admin meeting:read:alert:admin meeting:read:participant_sharing:admin meeting:read:device:admin meeting:read:risk_alert:admin meeting:read:chat_message:admin meeting:read:list_upcoming_meetings:admin meeting:read:past_qa:admin meeting:read:token:admin meeting:read:ai_companion:admin",
"api_url": "https://api-us.zoom.us"
}

The curl code I’m using:

curl --location --request POST 'https://zoom.us/oauth/token?grant_type=account_credentials&account_id={redacted}' \
--header 'Authorization: Basic {redacted}' \
--header 'Content-Type: application/x-www-form-urlencoded'

Please note I am using grant_type=account_credentials, when I try grant_type=authorization_code, I get an error 400 invalid request error. Also note that the token works for meeting create, meeting details, update meeting and list meetings. Does registration require a different type of token than all the other end points?

@siggib grant_type=account_credentials is used for S2S OAuth. User OAuth uses grant_type=authorization_code to exchange for access token

When the callback happens, you will get a code in the query string, this code query string is used to exchanged for an user oauth access token.

you must provide the correct credentials, and the same identical redirectUri as specific in marketplace.zoom.us settings, the code which was in the query string.

If you are using grant_type=account_credentials, chances are the token is a S2S token and not the User OAuth token.

const credentials = Buffer.from(${clientId}:${clientSecret}).toString(‘base64’);
const redirectUri = 'https://nodejs.asdc.cc/redirecturlforoauth?type='+type;

log('DEBUG', 'Exchanging OAuth code', {
  requestId: req.requestId,
  redirectUri,
  clientIdPrefix: clientId ? clientId.substring(0, 8) + '...' : 'MISSING',
  hasClientSecret: !!clientSecret
});

const response = await axios.post('https://zoom.us/oauth/token',
  new URLSearchParams({
    code: code,
    grant_type: 'authorization_code',
    redirect_uri: redirectUri
  }).toString(),
  {
    headers: {
      'Authorization': `Basic ${credentials}`,
      'Content-Type': 'application/x-www-form-urlencoded'
    }
  }
);

log('INFO', 'OAuth token exchange success', { requestId: req.requestId });

res.status(200).json(response.data);

@siggib meeting:read:meeting:admin seems to be an admin oauth scope, could you check if the marketplace app is user oauth or admin oauth?

@chunsiong.zoom I’m sorry I seem to have neglected to state that I’m working on an S2S app, not a UserApp; my apologies for that oversight. How does that change the situation?

How do I check if the app is user oauth or admin oauth. I’m assuming that since it is an S2S app, that it’s admin oAuth, but I hate to assume.

Hi @siggib ,

To check what is the type of app. you will need to go to marketplace.zoom.us, to check out what is type of app.

If you are using S2S, it is fairly straight forward, no redirect around at all. Just do something like this

You will use your key and secret encoded in base64 to get the access token. Grant type is account_credentials, remember to include your account ID when making the request.

app.get(‘/s2soauth’, async (req, res) => {
try {
const clientId = process.env.ZOOM_S2S_CLIENT_ID;
const clientSecret = process.env.ZOOM_S2S_CLIENT_SECRET;
const accountId = process.env.ZOOM_S2S_ACCOUNTID;

log('DEBUG', 'S2S OAuth request', {
  requestId: req.requestId,
  hasClientId: !!clientId,
  hasClientSecret: !!clientSecret,
  hasAccountId: !!accountId
});

const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const url = `https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${accountId}`;

const response = await axios.post(url, null, {
  headers: {
    'Authorization': `Basic ${credentials}`,
    'Content-Type': 'application/x-www-form-urlencoded'
  }
});

log('INFO', 'S2S OAuth success', { requestId: req.requestId });
res.status(200).json(response.data);

} catch (error) {
log(‘ERROR’, ‘S2S OAuth failed’, {
requestId: req.requestId,
error: error.message,
response: error.response?.data
});
res.status(500).json({ error: error.message });
}
});


@chunsiong.zoom

Here is what the marketplace says about my app

FooEvents

Intend to publish: No Account-level app Server-To-Server OAuth

I’ve got the authentication flow set up as you said, and the token works great for

  • GET v2/users/me/meetings?type=upcoming (meeting list)
  • GET v2/meetings/{{MeetingID}} (Meeting details)
  • PATCH v2/meetings/{{MeetingID}} (Meeting update)
  • POST v2/users/me/meetings (New Meeting)

The only issue I am having is with POST v2/meetings/{{MeetingID}}/registrants I get a 401 Unauthorized {“code”: 124,“message”: “Invalid access token.”} on that one.

When I do a GET v2/meetings/{{MeetingID}} immediately after, I get the meeting details. MeetingID and token are both Postman variables, and both calls use the same bearer-type authentication configured by Postman, referencing the same variable. So there is no risk of typos in this flow.

Why would I get an invalid token on POST v2/meetings/{{MeetingID}}/registrants when that exact same token works great for GET v2/meetings/{{MeetingID}}

It goes without saying that all those calls are against https://api.zoom.us/

@siggib can you double check on the authorization tab within postman, it is the same auth type?

In postman, unless you specify it, there is a default auth (Oauth 2.0) which might not be the S2S token