LogoRecal

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:

  1. Get a fresh authorization URL — GET /v1/users/{userId}/oauth/{provider}/link, or recal.oauth.getAuthLink(userId, provider). For insufficientScopes, request free-busy or higher.
  2. Redirect the user to it and let them approve.
  3. Exchange the code — POST /v1/users/oauth/{provider}/verify, or recal.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:

RouteThe list
GET /v1/users/{userId}/calendar/busyfailedCalendars beside data
GET /v1/organizations/{orgSlug}/calendar/busyfailedUsers beside data
GET /v1/organizations/{orgSlug}/schedulingfailedUsers beside data
POST /v1/users/schedulingfailedCalendars 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.
  • failedCalendars non-empty — partial. data already 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 explicit calendarIds for 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

reasonWhat happenedWho fixes itWhat to show your userHow to resolve it
authorizationInvalidThe stored authorization was rejected or could not be refreshedYour user"We can't read your calendar any more. Reconnect it to keep your availability up to date."Reconnect through the link flow
insufficientScopesThe authorization does not include permission to read busy timesYour userSame reconnect promptReconnect requesting free-busy or higher
providerNotConfiguredYour organization has no OAuth credentials for that providerYouNothingAdd the provider's credentials in the dashboard
notFoundThe calendar does not exist or is no longer subscribedYou, or nobody"We can't read the calendar Team events."Drop the id, or wait out the one-hour calendar-list cache
accessDeniedThe calendar is not shared with this userYour user"Ask its owner to share Team events again, or remove it."Re-share the calendar, or drop the id
mailboxUnavailableThe user has no calendar mailbox on that provider at allYour 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
invalidCalendarIdThe id is not valid for that providerYouNothingFix the id you are sending
tooManyCalendarsThe user has more calendars than one lookup can readYouNothingPass explicit calendarIds
rateLimitedThe provider throttled the requestNobodyNothing — show the last good answerRetry with backoff
providerErrorThe provider could not be readNobodyNothing — show the last good answerRetry 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.

StatusWhen
400Invalid 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
401The provider itself rejected the stored token
403Insufficient scopes; every requested calendar was accessDenied
404The user does not exist; every requested calendar was notFound
429The 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
502The 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.


On this page