
On a recent client project we run a training platform built on Oracle APEX: an admin app for the head office and a learning app for employees. Live webinars were already part of it, but everything around the meeting itself was manual.
- The admin created a meeting somewhere and copied the link by hand into the webinar slot.
- The link was visible in the page source for everyone.
- After the webinar the admin ticked attendance person by person, from memory or from a screenshot.
- Nothing stopped two webinars from running at the same time.
Plus two wishes from the team: a calendar that shows all webinars, and no overlapping webinars. Here's how we built all of it in pure PL/SQL, with no middleware and no extra server.
- The result at a glance
- Architecture
- The data model
- The complete scenario, with real data
- Authentication: one organizer account, one refresh token
- Creating the Meet link
- "Join now" is the attendance
- Checking attendance against Google's participant list
- A calendar, and no overlapping webinars
- Testing: a Google mock first, then the real thing
- Lessons learned
- Security and privacy
- Is this approach worth it?
The result at a glance

| Step | What happens |
|---|---|
| 1. Admin saves a time slot | A Google Calendar event with a Meet link is created. The URL field stays empty and the link appears by itself. |
| 2. Employee applies | Registration in the learning app. The webinar appears in the new Calendar tab. |
| 3. Webinar day: "Join now" | The app checks the registration and the time window, marks the employee ATTENDED, and redirects to Google Meet. |
| 4. After the webinar | A scheduler job reads the real participant list from Google and adds the time each person spent in the meeting. |
| 5. Admin | Sees attendance, Meet time and anything unusual on one screen. |
Architecture

No middleware. Every call to Google goes straight from the database through APEX_WEB_SERVICE. That keeps the integration inside the normal deployment process (SQL scripts plus APEX exports), and inside the existing table APIs and history tables.
The data model
We didn't add a single table. The integration builds on three existing ones and adds seven columns. All our tables follow the same standard: a 4-letter column prefix, audit columns, soft delete, and a history table (H_<TABLE>) filled by a trigger. The new columns went in through our code generator, which regenerates the table API package (API_<TABLE>_PKG) and the history table and trigger. Application code never writes to the tables directly.

WEBINAR: the topic (unchanged)
| Column | Type | Meaning |
|---|---|---|
WEBI_ID | NUMBER, PK | Webinar id |
WEBI_TITLE | VARCHAR2(500) | Title, also used as the Google Calendar event title |
WEBI_DESCRIPTION | CLOB | Description. The first 1,500 characters go into the Google event |
WEBI_SUAD_FK | NUMBER, FK to SUPER_ADMIN | Host |
WEBI_STATUS | VARCHAR2(20) | DRAFT, PUBLISHED, CANCELLED, COMPLETED (check constraint) |
WEBI_SHOW_REGION_YN, WEBI_REGION_FK | Audience by region | |
WEBI_SPECIFIC_FOR_COMP_YN, WEBI_COMP_FK | Audience by company | |
WEBI_ACTIVE_YN, WEBI_DELETED_YN | Active flag, soft delete |
WEBINAR_SLOT: one date of a webinar (4 new columns)
| Column | Type | Meaning |
|---|---|---|
WESL_ID | NUMBER, PK | Slot id |
WESL_WEBI_FK | NUMBER, FK to WEBINAR | Webinar |
WESL_SCHEDULED_DATE | DATE | Day |
WESL_START_TIME, WESL_END_TIME | VARCHAR2 'HH24:MI' | Time, in the organisation's local time zone |
WESL_MAX_SEATS | NUMBER | Seats |
WESL_MEETING_URL | VARCHAR2 | Meet link, now filled automatically |
WESL_GOOGLE_EVENT_ID (new) | VARCHAR2(1024) | Google Calendar event id, used to update or cancel it |
WESL_GOOGLE_SYNC_STATUS (new) | VARCHAR2(20) | OK, ERROR, CANCELLED |
WESL_GOOGLE_SYNC_MESSAGE (new) | VARCHAR2(4000) | Last Google error, or the attendance summary for the admin |
WESL_ATTENDANCE_DONE_YN (new) | VARCHAR2(3), default NO | Google attendance already loaded |
EMPLOYEE_WEBINAR: registration and attendance (3 new columns)
| Column | Type | Meaning |
|---|---|---|
EMWE_ID | NUMBER, PK | Registration id |
EMWE_WESL_FK | NUMBER, FK to WEBINAR_SLOT | Slot |
EMWE_EMPL_FK | NUMBER, FK to EMPLOYEE | Employee |
EMWE_STATUS | VARCHAR2(20) | REGISTERED, CANCELLED, ATTENDED, NO_SHOW (check constraint) |
EMWE_REGISTERED_DATE | TIMESTAMP | When the employee applied |
EMWE_JOINED_DATE (new) | TIMESTAMP | First click on "Join now" |
EMWE_ATTENDED_SECONDS (new) | NUMBER | Time in the meeting, according to Google |
EMWE_ATTENDANCE_SOURCE (new) | VARCHAR2(20) | JOIN (joined through the app), GOOGLE (from the participant list), MANUAL (set by the admin) |
GLOBAL_PARAMETER: configuration
Everything is entered on a settings page, never in a script.
| Name | Content |
|---|---|
GOOGLE_CLIENT_ID | OAuth client id of the organizer's Google Cloud project |
GOOGLE_CLIENT_SECRET | OAuth client secret (never displayed) |
GOOGLE_REFRESH_TOKEN | Long-term access for the organizer account (never displayed) |
GOOGLE_CALENDAR_ID | Calendar for the events, default primary |
GOOGLE_WALLET_PATH / _PASSWORD | Certificates for HTTPS (system: on Oracle 23ai, a file wallet on 19c) |
WEBINAR_ATTENDANCE_MIN_PCT | Minimum share of the slot for ATTENDED via Google, default 50 |
GOOGLE_CONNECTED_AT, GOOGLE_LAST_TEST | Status shown on the settings page |
GOOGLE_OAUTH_STATE | One-time value that protects the connect step |
Code objects
| Object | Role |
|---|---|
UTIL_GOOGLE_PKG | OAuth token, HTTPS calls, settings page logic (save, connect, test, disconnect, status) |
UTIL_WEBINAR_PKG | Overlap check, learner visibility, join, Google sync per slot and webinar, attendance import |
WEBINAR_REGISTER_PKG | Registration. The seat count now includes ATTENDED, so a learner who joined keeps the seat |
API_WEBINAR_SLOT_PKG, API_EMPLOYEE_WEBINAR_PKG | Generated table APIs (all writes go through them) |
H_WEBINAR_SLOT_TRG, H_EMPLOYEE_WEBINAR_TRG | Generated history triggers |
Job WEBINAR_ATTENDANCE_JOB | Every 15 minutes: attendance for slots that ended between 30 minutes and 7 days ago |
| Admin app: Google Meet Settings page | Connect, test and disconnect the Google account |
| Admin app: slot dialog | Overlap validation and the process "Google Meet sync" |
| Admin app: attendance dialog | Google status and "Load attendance from Google Meet" |
Learning app: Ajax process JOIN_WEBINAR | The endpoint behind "Join now" |
The complete scenario, with real data
Let's follow one webinar from start to finish: "First Aid Basics" on 5 November 2026, 10:00 to 11:00, with 20 seats, and an employee called Anna Schmidt. The values are examples and the ids are illustrative.
The admin enters the client id and secret, clicks Connect, approves with the organizer account and clicks Finish. Afterwards:
| GLPA_NAME | GLPA_VALUE |
|---|---|
GOOGLE_CLIENT_ID | 1234...apps.googleusercontent.com |
GOOGLE_CLIENT_SECRET | (stored, hidden) |
GOOGLE_REFRESH_TOKEN | (stored, hidden) |
GOOGLE_CONNECTED_AT | 16.10.2026 08:15 by ADMIN |
GOOGLE_LAST_TEST | 16.10.2026 08:15: OK - login and calendar access work |

| WEBI_ID | WEBI_TITLE | WEBI_SUAD_FK | WEBI_STATUS |
|---|---|---|---|
| 120 | First Aid Basics | 7 | PUBLISHED |
Nothing goes to Google yet. The Meet link belongs to a date, not to the topic.
- Validation
UTIL_WEBINAR_PKG.fn_slot_conflict: is the end after the start? Is any other active slot of a non-cancelled webinar at the same time? None, so saving continues. - Create through
API_WEBINAR_SLOT_PKG. - Process "Google Meet sync" calls
pr_google_sync_slot:POST calendar/v3/calendars/primary/events?conferenceDataVersion=1creates the event with a Meet conference,PATCH meet/v2/spaces/{space}setsaccessType = OPEN, and the result is written to the slot.

| WESL_ID | DATE | TIME | SEATS | WESL_MEETING_URL | EVENT_ID | SYNC |
|---|---|---|---|---|---|---|
| 310 | 05.11.2026 | 10:00 to 11:00 | 20 | https://meet.google.com/abc-defg-hij | k3q9... | OK |
The event also appears in the organizer's Google Calendar. If the admin later changes the time, the same event is patched and the link stays. If the slot is deleted or the webinar cancelled, the event is cancelled at Google and the sync status becomes CANCELLED.
UTIL_WEBINAR_PKG.fn_visible_for_employee decides whether Anna sees the webinar at all (everyone, her region, or her company). WEBINAR_REGISTER_PKG checks the seats: REGISTERED plus ATTENDED must be below 20.
| EMWE_ID | WESL_FK | EMPL_FK | STATUS | REGISTERED | JOINED | SECONDS | SOURCE |
|---|---|---|---|---|---|---|---|
| 5001 | 310 | 842 (Anna) | REGISTERED | 20.10.2026 14:02 | - | - | - |
The webinar now shows in Anna's Calendar tab, in green because she's registered.


The Ajax process JOIN_WEBINAR calls UTIL_WEBINAR_PKG.fn_join(310, ...):
- Registered? Yes. Webinar published? Yes.
- Is 09:52 at or after 09:45 (15 minutes before the start) and before 11:00? Yes.
- Update through
API_EMPLOYEE_WEBINAR_PKG, return the Meet URL, and the browser opens Google Meet.
| EMWE_ID | STATUS | JOINED | SECONDS | SOURCE |
|---|---|---|---|---|
| 5001 | ATTENDED | 05.11.2026 09:52:10 | - | JOIN |
The old row version is kept in H_EMPLOYEE_WEBINAR. If Anna clicks Join now again, say after a network problem, she's simply redirected again and her seat stays taken. Too early, not registered or after the end? fn_join returns a message instead of the link, for example "The webinar opens 15 minutes before the start (05.11.2026 10:00)."
Anna is in the meeting from 09:53 to 10:58. Ben, another registered colleague, never clicks "Join now" and doesn't attend. Clara gets the Meet link from Anna and opens it directly instead of using the app's button. She stays 40 minutes.
At 11:30 the job WEBINAR_ATTENDANCE_JOB runs pr_google_sync_attendance(310):
GET meet/v2/conferenceRecords?filter=space.meeting_code="abc-defg-hij"finds the conference record, which must be ended.GET .../participantsandGET .../participantSessionsgive the time per participant.- Names are matched against the registrations (letters only, both name orders).
- Minimum time is 50 % of 60 minutes, so 30 minutes.
| Employee | Before | Google time | After | Why |
|---|---|---|---|---|
| Anna | ATTENDED / JOIN | 65 min | ATTENDED / JOIN, 3,900 s | Joined through the app, stays, time added |
| Clara | REGISTERED | 40 min | ATTENDED / GOOGLE, 2,400 s | At least 30 minutes without the app button |
| Ben | REGISTERED | - | NO_SHOW / GOOGLE | Not in the meeting |
Slot 310 gets WESL_ATTENDANCE_DONE_YN = YES and the message "Attendance 05.11.2026 11:30: 2 attended, 1 no-show (min. 30 min)." If someone had joined through the app but stayed only 5 minutes, or wasn't found in Meet, the message would list them for checking. Meet participants without a registration are listed as well.
The attendance dialog shows the list and a Google box with the link, the sync status and the summary. The admin can still change any line by hand. That sets EMWE_ATTENDANCE_SOURCE = MANUAL, and later Google imports never overwrite it. Load attendance from Google Meet runs the import on demand.


Authentication: one organizer account, one refresh token
All webinars are created in the calendar of one dedicated organizer account. The app holds an OAuth 2.0 refresh token for that account with exactly three scopes:
| Scope | Why |
|---|---|
calendar.events | Create, update and cancel webinar events with Meet links |
meetings.space.settings | Open the meeting for learners (no "ask to join") |
meetings.space.readonly | Read the participant list after the webinar |
Why not a service account? A service account signs its own tokens with RSA. Older Oracle versions can't do that in PL/SQL, and service accounts can't create Meet links in a normal Google account without Workspace domain-wide delegation. A refresh token only needs a plain HTTPS POST, so it works on every database version.
The access token is cached for the session:
Connecting the account without SQL
On production nobody should have to run SQL scripts with secrets in them, so the admin app got a Google Meet Settings page:
- Enter the Client ID and Client Secret (the secret is never shown again), then Save.
- Connect shows Google's approval link. Sign in with the organizer account and click Allow.
- Paste the address the browser ends up on, then click Finish.
- The page runs a test straight away: "Google account connected. Test: OK".
There's also Test connection and Disconnect, which revokes the token at Google. A state value stored on the server protects the approval step against foreign or replayed codes.
Creating the Meet link
When the admin saves a slot, an APEX process calls the sync procedure. It builds a Calendar event with a conference create request:
The response contains hangoutLink, the Meet URL, which is stored on the slot. Google may create the conference asynchronously, so the code asks again a few times if the link is still missing.
Then the meeting space is opened, so learners from outside the organisation join without knocking:
Changes and cancellations are synchronised too: a changed time patches the same event (the link stays), a deleted slot or a cancelled webinar cancels the event. And Google errors never block saving. They're stored on the slot and shown to the admin.
"Join now" is the attendance
The old "Join now" button was a plain <a href="{meeting url}">. The new one calls an Ajax process:
On the server, UTIL_WEBINAR_PKG.fn_join checks that:
- the employee is registered for this slot,
- the webinar is published,
- it's between 15 minutes before the start and the end, in local time.
Then it marks the registration ATTENDED (source JOIN), logs the first join time and returns the URL. The employee can rejoin until the end, and a learner who joined keeps the seat.

Checking attendance against Google's participant list
Thirty minutes after a webinar ends, the scheduler job reads the meeting from the Meet REST API:
For each participant the sessions are added up to the time in the meeting. Then:
| Employee | Result |
|---|---|
| Joined through the app | Stays ATTENDED. Google adds the real time. Short stays or "not found in Meet" are listed for the admin |
| Entered Meet without the app button, at least 50 % of the slot | ATTENDED |
| Not in the meeting | NO_SHOW |
| Marked by hand by the admin | Kept |
A calendar, and no overlapping webinars
- The learning app got a Calendar tab (APEX CSS Calendar) with exactly the webinars an employee is allowed to see. The visibility rule (everyone, region or company) moved into one PL/SQL function, and we checked it against the old card logic: 602 employee and webinar pairs, 0 differences. Own registrations are green.
- The admin app shows drafts in the calendar too, because they block their time.
- Overlap check: a slot can't be saved if it overlaps any active slot of another non-cancelled webinar, and the end must be after the start. The same check runs when a cancelled webinar is reactivated.

Testing: a Google mock first, then the real thing
We wrote browser tests (Playwright) for the whole flow, plus a small Google mock server (Python standard library only) that imitates the OAuth, Calendar and Meet endpoints. All Google base URLs are configurable, so the tests point the app at the mock and never create real events. The test runner even switches a real Google connection off during the run and restores it afterwards.
The end-to-end test does exactly what the business asked for: admin creates a webinar, the Meet link appears, a second slot at the same time is refused, an employee applies, Join now opens the Meet link and marks ATTENDED, and after the webinar the admin sees the attendance. Full suite: 65 tests passing.
Then the real test: a live meeting with seven participants on a local copy, and a second real webinar on the development environment. Meet link generated, join marked, participant list read, attendance loaded on the admin page.
Lessons learned
system: uses the operating system's certificate store, so no wallet is needed. On 19c you need a wallet with Google's root certificates (GTS Root R1 to R4, GlobalSign R4).connect and resolve together with a port range fails with ORA-24244. Grant connect for port 443 and resolve without ports. And do it for both the schema and the APEX engine user, because APEX_WEB_SERVICE runs as the APEX user.q'~...~' literal lost that space in SQL*Plus, so the script safely refused to patch. The fix: 'X' || ' ' || q'~... keeps the text byte-identical.Security and privacy
- Secrets (client secret, refresh token) live only in the database configuration and in root-only files on the server. Never in the repository, never in chat, never on a page.
- The meeting link is only handed out to registered employees, inside the join time window.
- A dedicated organizer account with the minimum scopes. Access can be revoked any time in the Google account, or with one click on the settings page.
- Test and production use different Google accounts.
Is this approach worth it?
With two PL/SQL packages, seven new columns, a handful of APEX changes and no extra infrastructure, the platform now:
- ✓generates Google Meet links automatically when a slot is saved,
- ✓lets employees join with one click and marks their attendance at that moment,
- ✓checks attendance against Google's real participant list,
- ✓shows all webinars in a calendar and prevents overlaps,
- ✓and lets the admin manage the Google connection on one settings page.
The admin's manual work went from copy the link, collect screenshots, tick attendance to saving a time slot. If you're planning something similar in your own APEX app and get stuck, leave a comment below.
Stack: Oracle Database 19c / 23ai, Oracle APEX 23.2, Google Calendar API v3, Google Meet REST API v2.
Comments
Post a Comment