Created with the event
onlineMeetingService: "zoom" or
"webex" on create; the join link becomes the event's
onlineMeetingUrl on Google, Microsoft 365 and CalDAV.
Name a service when you create a calendar event and workwae creates the meeting, writes its join link into the event, moves it when the event moves and deletes it when the event is cancelled. Hosted by the user's own grant or by your organization's server app.
const timeZone = "America/Chicago";
// A weekly event gets a recurring Zoom meeting
const event = await workwae.calendarEvents.create({
accountId,
calendarId,
subject: "Customer onboarding",
start: { dateTime: "2026-11-03T15:00:00", timeZone },
end: { dateTime: "2026-11-03T15:45:00", timeZone },
attendees: [{ address: "sam@example.com" }],
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=8"],
onlineMeetingService: "zoom",
});
event.onlineMeetingUrl; // the Zoom join link, on the event
// Moving the series moves the Zoom meeting too
await workwae.calendarEvents.update({
accountId,
eventId: event.id,
start: { dateTime: "2026-11-03T16:00:00", timeZone },
end: { dateTime: "2026-11-03T16:45:00", timeZone },
});
One field on event create. workwae handles the service's OAuth, the host, the meeting's recurrence and its lifecycle, and tells you when a grant needs attention.
onlineMeetingService: "zoom" or
"webex" on create; the join link becomes the event's
onlineMeetingUrl on Google, Microsoft 365 and CalDAV.
Each account's user links their own Zoom or Webex account on a hosted page, with the platform's sign-in app or your own. Meetings are hosted by that service user.
A Zoom Server-to-Server OAuth app or a Webex Service App hosts meetings for every mailbox in the organization, as the service user with the mailbox's address, with no per-user sign-in.
A series gets a recurring meeting translated from its rule. A rule the service cannot
repeat by is refused with meeting_recurrence_unsupported
before anything is created.
A new subject, start, end or rule moves the meeting; deleting or cancelling the event deletes it. A failed create leaves no meeting behind.
A grant the service refuses is marked reauth_required and
announced as meeting_connection.reauth_required. Mail and
calendar sync go on.
workwae only creates, updates and deletes meetings and identifies the host. It never reads recordings, chat, transcripts or participant lists.
Grants and app secrets are encrypted and bound to their own record, so a value copied onto another account or app does not open there.
One link per user, valid for an hour by default, that returns to your app with the outcome. It shows who is asking before anything is connected.
For each event, workwae uses the account's own grant at the service first, then your
tenant's server app for that service, and otherwise refuses with
meeting_service_not_connected. A grant that needs
reconnecting is not passed over for the app.
| Option | Zoom | Webex | Who hosts |
|---|---|---|---|
| User grant, platform app | User OAuth with PKCE through workwae's Zoom app | Integration OAuth with PKCE through workwae's Webex integration | The service user who signed in |
| User grant, your own app | Your Zoom OAuth app (zoom_oauth) |
Your Webex integration (webex_oauth) |
The service user who signed in |
| Server app | A Server-to-Server OAuth app (zoom_s2s) with the account ID, client ID and secret; the host is looked up by email |
A Service App (webex_service_app) an organization administrator authorized, with its client ID, secret and refresh token |
The organization's user with the mailbox's address |
| Scopes | meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, user:read:user; their :admin versions for a server app |
meeting:schedules_write, meeting:schedules_read, spark:people_read; meeting:admin_schedule_write, meeting:admin_schedule_read, spark-admin:people_read for a Service App |
Each registered app lists the scopes to grant it |
| Tokens | Every refresh rotates the refresh token; refreshes are serialized so two processes never void a healthy grant. A server app's token lasts an hour | Access tokens last 14 days, refresh tokens 90 days; a Service App refreshes like an integration | A 401 or 403 is retried once with a fresh token |
meeting_service_not_connected before the app is asked for anything.
meeting_host_missing. Test an app, optionally with a user's address, to see
each check before it hosts a meeting.
onlineMeetingService answers plan_module_unavailable.
Each service has its own limits on times and repeats. workwae translates what it can, clamps what it must, and refuses a rule it cannot carry over before anything is created.
| Aspect | Zoom | Webex |
|---|---|---|
| Times | The event's wall-clock start and time zone; the duration in whole minutes, from 1 to 1,440 | The start cannot be in the past; the end is clamped to 10 minutes through 23 hours 59 minutes after it |
| Repeats | Daily (interval up to 99), weekly on chosen weekdays (up to 50), monthly on one day or one weekday of one week (up to 10), ending after up to 60 occurrences, at a date, or never; FREQ=DAILY;BYDAY=... becomes weekly |
The event's RRULE |
Refused with meeting_ |
Yearly rules, RDATE, EXDATE, several days or dates a month, and WKST with several days every second week or more | Monthly or yearly rules with several days or dates, RDATE and EXDATE |
| On update | The recurrence is sent only when the event's rule changed; Zoom keeps its own otherwise | Subject, times and rule follow the event |
| Notifications | Deletes skip Zoom's cancellation reminder | Webex sends no email on any write |
onlineMeetingUrl other than the meeting's own deletes
the meeting; sending its own link back changes nothing.
Create a hosted session and send the user to its link. They sign in at Zoom or Webex, then return to your redirect URI with the outcome, or to workwae's own confirmation page.
session_used, and one after expiresAt with
session_expired. Connecting again replaces the account's grant.
meetingAppId; register the
redirect URI the app record lists at the service.
const { url } = await workwae.meetingConnectSessions.create({
accountId,
service: "webex",
redirectUri: "https://app.example.com/settings/meetings",
state: "user-42",
ttlSeconds: 1800,
});
// Send the user to url. They come back with
// ?status=success&service=webex&state=user-42
// Later: what is connected, and is it healthy?
const { items } = await workwae.meetingConnections.list({
accountId,
});
for (const connection of items) {
console.log(
connection.service, // "zoom" | "webex"
connection.email, // the host
connection.status, // "active" | "reauth_required"
connection.statusReason,
);
}
// Disconnect: the grant is deleted, meetings stay
await workwae.meetingConnections.delete({
accountId,
service: "webex",
});
Register your Zoom Server-to-Server OAuth app or Webex Service App once. Every account without a grant of its own then gets meetings hosted by its own user at the service.
meeting_app_exists.
unverified; a
test requests a token the way meetings will and marks it active or
failing.
failing, and the event create answers
meeting_service_reauth_required.
const app = await workwae.meetingApps.create({
kind: "zoom_s2s",
name: "Acme Zoom",
accountId: zoomAccountId, // from the app's credentials page
clientId,
clientSecret,
});
app.scopes; // what to grant the app in the Zoom Marketplace
// Token, then a host lookup for one address
const { app: checked, checks } = await workwae.meetingApps.test({
appId: app.id,
user: "priya@acme.com",
});
for (const check of checks) {
console.log(check.name, check.isOk, check.detail);
}
checked.status; // "active" | "failing"
// A Webex Service App takes the refresh token instead
await workwae.meetingApps.create({
kind: "webex_service_app",
name: "Acme Webex",
clientId: webexClientId,
clientSecret: webexClientSecret,
refreshToken: webexRefreshToken,
});
Meeting errors are typed reasons, not service error strings, and a refused grant is announced once as an event, so you can prompt the right user to reconnect.
reauth_required.
meeting_connection.reauth_required with reason
app_deauthorized.
import { isApiError, parseWebhook } from "@workwae/sdk";
try {
await workwae.calendarEvents.create({
...booking,
onlineMeetingService: "zoom",
});
} catch (error) {
if (
isApiError(error, "meeting_service_not_connected") ||
isApiError(error, "meeting_service_reauth_required")
) {
return askUserToConnect(booking.accountId, "zoom");
}
if (isApiError(error, "meeting_recurrence_unsupported")) {
return suggestSimplerRule(booking);
}
throw error;
}
// In your webhook handler
const event = await parseWebhook(secret, request.headers, body);
const reauth = "meeting_connection.reauth_required";
if (event.known && event.type === reauth) {
// reason: e.g. "invalid_grant" or "app_deauthorized"
const { service, email, reason } = event.data;
await notifyUser(event.accountId, service, email, reason);
}
Connection changes arrive as webhooks and feed items; meeting problems on an event write answer with a stable reason. See webhooks and the change feed.
| Event or reason | Meaning |
|---|---|
meeting_ |
An account connected or reconnected its own grant; data: service, email, reconnected |
meeting_ |
The service refused the account's grant; data: service, email, reason |
calendar_ |
The event changed; a new or removed join link shows as onlineMeetingUrl in changes |
meeting_ |
No grant at the service and no server app the account may use |
meeting_ |
The account's grant or the tenant's server app was refused and must be reconnected or fixed |
meeting_ |
The server app has no user with the mailbox's address |
meeting_ |
The service cannot repeat a meeting by the event's rule |
meeting_ |
The event's meeting is gone at the service when workwae tries to move it |
meeting_, meeting_ |
The service refused the meeting, or could not be reached |
multiple_ |
More than one of onlineMeeting, onlineMeetingUrl and onlineMeetingService |
No. Users can link their accounts through workwae's platform apps. Register your own Zoom OAuth app or Webex integration to have users sign in through it instead, or a server app to host for a whole organization without per-user sign-in.
The grant is deleted (and revoked at Zoom), and meetings already created stay at the service with their links on the events. Later events use the tenant's server app, if there is one the account may use.
A server app hosts as the organization's user with the mailbox's address. For a password IMAP account that address is whatever was typed, so workwae requires the user's own grant instead of trusting it.
Yes. The occurrence changes on the calendar, and the service's recurring meeting stays as it is; its join link works for every occurrence. Changing the series' times or rule moves the meeting.
The calendar does. Zoom and Webex are told not to email attendees, so people get one invitation with the join link in it.
Use cases that book meetings, and the features around them. Using Zoom as an end user? See Using workwae with Zoom.
Find free time, book the event and attach the meeting in one flow.
Interviews on real calendars, with a Zoom or Webex link that moves with them.
Every calendar, recurring series, RSVP and free/busy across providers.
Hosted and headless connect flows for mailboxes and calendars.
workwae is in early access. Tell us what you are building, and read the docs in the meantime.