Troubleshooting
What a partial availability answer means, how to detect it, and what to show your users.
Availability answers can come back incomplete. An authorization expires, a user un-subscribes from a calendar, a provider throttles a request — and one of the calendars behind an answer cannot be read.
Recal returns what it could read and names what it could not, so the gap is something you can act on instead of something that silently makes a busy person look free.
Every named failure carries a reason from a closed set of ten codes. If you already have one in
front of you, skip to the reason reference.
Reconnect a calendar
authorizationInvalid and insufficientScopes mean the stored authorization no longer lets Recal
read the calendar. Nothing in your code fixes either one — the user has to grant access again.
Send them back through the same link flow you used to connect them:
- Get a fresh authorization URL —
GET /v1/users/{userId}/oauth/{provider}/link, orrecal.oauth.getAuthLink(userId, provider). ForinsufficientScopes, requestfree-busyor higher. - Redirect the user to it and let them approve.
- Exchange the code —
POST /v1/users/oauth/{provider}/verify, orrecal.oauth.verifyCode(...).
Verifying replaces the stored connection, so there is no need to DELETE the old one first.
The usual cause in development
A Google OAuth app still in testing mode issues refresh tokens that expire after about seven
days. Every connection made through it reports authorizationInvalid a week later. Moving the app
to production before launch is the fix — see Google setup.
What to show your user: "We can't read your calendar any more. Reconnect it to keep your availability up to date."
On the single-user routes these two are not partial answers at all: a dead authorization answers 400 and insufficient scopes answers 403. The partial form appears only on the organization routes and the multi-user scheduling route.
Partial results
A partial answer is a 200. data has its ordinary shape and holds everything that could be read;
a list beside it names everything that could not.
The invariant
If a connected calendar or a connected member is not in data, it is in the list. A 200
never drops one silently.
Four routes can answer partially:
| Route | The list |
|---|---|
GET /v1/users/{userId}/calendar/busy | failedCalendars beside data |
GET /v1/organizations/{orgSlug}/calendar/busy | failedUsers beside data |
GET /v1/organizations/{orgSlug}/scheduling | failedUsers beside data |
POST /v1/users/scheduling | failedCalendars on each ok entry; reason on each error entry that came from a calendar provider |
GET and POST /v1/users/{userId}/scheduling read one calendar, so there is nothing to be partial
about — they answer a status code instead.
One exception on the multi-user route: with maxOverlaps > 0, a calendar that cannot be read fails
that user's whole entry rather than trimming it, so you get a status: 'error' entry instead of an
ok one. A populated failedCalendars is a maxOverlaps: 0 phenomenon.
One user's busy times
{
"data": [
{ "start": "2026-01-05T09:00:00.000Z", "end": "2026-01-05T10:00:00.000Z" }
],
"failedCalendars": [
{
"calendarId": "old-team@group.calendar.google.com",
"provider": "google",
"reason": "notFound",
"message": "The calendar does not exist or is no longer subscribed"
}
]
}An organization's busy times
Each entry names the member by their customId. Its reason is the most severe of their calendars'
reasons, so you can switch on one field and drill into failedCalendars when you need the detail.
{
"data": [
{ "start": "2026-01-05T09:00:00.000Z", "end": "2026-01-05T11:00:00.000Z" }
],
"failedUsers": [
{
"customId": "bob",
"reason": "authorizationInvalid",
"message": "The stored calendar authorization is no longer valid",
"failedCalendars": []
},
{
"customId": "carol",
"reason": "notFound",
"message": "The calendar does not exist or is no longer subscribed",
"failedCalendars": [
{
"calendarId": "old-team@group.calendar.google.com",
"provider": "google",
"reason": "notFound",
"message": "The calendar does not exist or is no longer subscribed"
}
]
}
]
}The nested list is the difference between some and none:
failedCalendars: []— nothing of that member was readable. They contributed no busy time at all, so treat them as unknown, never as free. Bob is this case.failedCalendarsnon-empty — partial.dataalready holds the busy time from their other calendars, and the entries name what is missing. Carol is this case.
GET /v1/organizations/{orgSlug}/scheduling carries failedUsers in exactly the same shape beside
its usual { availableSlots, options }.
Members with no connected calendar are skipped
A member who has never connected a calendar, or who disconnected, is not listed — with or without
a provider= filter. Not being connected is a provisioning state you control and can read back
yourself, not a failure, so it is never a reason.
An organization that has not configured the provider at all is a different thing and is reported:
every one of its members comes back with providerNotConfigured. See
your configuration.
That means the lists tell you about connections that broke, not about connections that were never made. To know who is connected:
// Everyone in an organization, with their connections
const members = await recal.organizations.getMembers('acme-corp', {
include: ['oauthConnections']
})
// One user
const connections = await recal.oauth.list('user_id')The one exception is POST /v1/users/scheduling, which has always named them: an unconnected user
comes back as { status: 'error', error: 'User has no connected calendars' }, with no reason.
Detecting a partial answer
Both lists are always present — [] when nothing failed — so you can read .length without
a guard.
const { data, failedCalendars } = await recal.calendar.getBusyTimes(userId, range)
if (failedCalendars.length > 0) {
// data is incomplete; decide what to do before you book against it
}
const { data: orgBusy, failedUsers } = await recal.organizations.getBusyTimes(slug, range)
for (const user of failedUsers) {
switch (user.reason) {
case 'authorizationInvalid':
case 'insufficientScopes':
promptReconnect(user.customId)
break
default:
logForLater(user)
}
}On the multi-user scheduling route, check status on each entry and failedCalendars on the ok
ones.
Empty data is not the same as available
An empty data with a non-empty list means nothing could be read, not nothing is booked.
Booking flows should fail closed: show the slot as unavailable, or surface the reconnect prompt,
rather than offering a time you never actually checked.
Switch on reason; it is a closed union and safe to branch on. message is a fixed English sentence
for your logs — it is not written for your end users, and it never contains calendar ids, provider
text or anything else that varies per request.
Stale calendar ids
notFound and accessDenied mean one calendar in the list could not be read while the others were.
These are the everyday partials, and how you fix one depends on where the id came from.
You passed explicit calendarIds. The id is stale — drop it from whatever you store, and re-list
the user's calendars with GET /v1/users/{userId}/calendar to see what is actually there.
You did not pass calendarIds. Recal read the user's own subscribed list, which is cached for an
hour. A calendar the user removed keeps appearing until that clears, so a notFound on this path is
usually transient; if it persists past an hour, the user un-subscribed and nothing needs fixing.
accessDenied is a sharing problem rather than a stale one: the calendar exists but is no longer
shared with this user.
mailboxUnavailable looks similar and is not: it means the user has no calendar mailbox on that
provider in the first place — an Outlook account with no Exchange Online mailbox behind it. There is
nothing to re-share and nothing to drop. Their Microsoft administrator has to provision or licence
the mailbox; until then, do not query that provider for that user.
What to show your user: "We can't read the calendar Team events. Ask its owner to share it
again, or remove it." For mailboxUnavailable, tell them their Outlook account has no calendar yet.
Transient failures
rateLimited and providerError are the provider's side, not yours. The calendar is fine and the
authorization is fine; the request did not get through.
Retry with backoff — the retry helper
on the error-handling page works here too, driven by the list instead of a thrown error. If
providerError persists across retries, contact support.
There is nothing useful to show an end user for either. Keep displaying the last good answer and retry behind the scenes.
Your configuration
providerNotConfigured, invalidCalendarId and tooManyCalendars are fixed by you, not by your
user.
providerNotConfigured— your organization holds no OAuth credentials for that provider, so nothing can be read through it. Add them in the dashboard.invalidCalendarId— the id is not well-formed for that provider. Usually a Google id sent to Microsoft, or a truncated one. Fix the id.tooManyCalendars— the user is subscribed to more calendars than one lookup can read. Pass explicitcalendarIdsfor the ones you actually care about.
None of the three has anything to show an end user.
`providerNotConfigured` is not a quiet one
It is reported for every member of the organization, on every request, for as long as the
credentials are missing — an org-wide fault dressed as a per-member failure. Narrowing with
provider= does not sidestep it: naming an unconfigured provider fails the whole request rather
than trimming it to what you set up.
Reason reference
reason | What happened | Who fixes it | What to show your user | How to resolve it |
|---|---|---|---|---|
authorizationInvalid | The stored authorization was rejected or could not be refreshed | Your user | "We can't read your calendar any more. Reconnect it to keep your availability up to date." | Reconnect through the link flow |
insufficientScopes | The authorization does not include permission to read busy times | Your user | Same reconnect prompt | Reconnect requesting free-busy or higher |
providerNotConfigured | Your organization has no OAuth credentials for that provider | You | Nothing | Add the provider's credentials in the dashboard |
notFound | The calendar does not exist or is no longer subscribed | You, or nobody | "We can't read the calendar Team events." | Drop the id, or wait out the one-hour calendar-list cache |
accessDenied | The calendar is not shared with this user | Your user | "Ask its owner to share Team events again, or remove it." | Re-share the calendar, or drop the id |
mailboxUnavailable | The user has no calendar mailbox on that provider at all | Your user's Microsoft admin | "Your Outlook account doesn't have a calendar yet." | Have the mailbox provisioned or licensed — a re-share cannot fix it — or stop querying that provider for them |
invalidCalendarId | The id is not valid for that provider | You | Nothing | Fix the id you are sending |
tooManyCalendars | The user has more calendars than one lookup can read | You | Nothing | Pass explicit calendarIds |
rateLimited | The provider throttled the request | Nobody | Nothing — show the last good answer | Retry with backoff |
providerError | The provider could not be read | Nobody | Nothing — show the last good answer | Retry with backoff; if it persists, contact support |
The set is closed: a code is never renamed or removed once published, and a switch over these ten
values is complete today. New codes would be added in a future API version.
Whole-request failures
When nothing could be read there is no partial answer to give, so GET /v1/users/{userId}/calendar/busy
answers a status code instead of a 200. A status always means the whole lookup failed — never that
part of it did.
| Status | When |
|---|---|
400 | Invalid parameters; a dead authorization; the user has too many calendars; none of the requested providers is connected, or your organization has no credentials for them; every requested calendar was invalidCalendarId |
401 | The provider itself rejected the stored token |
403 | Insufficient scopes; every requested calendar was accessDenied |
404 | The user does not exist; every requested calendar was notFound |
429 | The provider throttled the whole lookup; every requested calendar was rateLimited. The whole-lookup form carries a Retry-After header; the per-calendar form does not |
502 | The provider could not be reached or answered with something unreadable; every requested calendar was providerError |
A dead authorization is a 400, not a 401
401 on these routes means the provider rejected the stored token. An expired or unrefreshable
authorization — by far the most common failure — answers 400. Both lead to the same place:
reconnect the calendar.
The organization routes do not have this table. They answer 200 with failedUsers whenever the
organization itself resolves, whatever happened to its members.
In the SDK these arrive as a thrown RecalError carrying statusCode — see
Error Handling.
Related
- See Error Handling for status codes and retries in the SDK
- See OAuth Setup for scopes and the connection flow
- See Calendar & Availability for busy-time queries
- See Organizations for team-wide availability