Recruiting pipeline: Gmail → Notion ATS → Calendar
Applications become candidate rows, screens get booked against real availability.
A lightweight ATS for teams that do not want a real one. Applications sent to a jobs alias are parsed into a Notion Candidates database with the résumé attached, dedupe against previous applications, and once a candidate moves to Screen the workflow checks the panel's free/busy and books a 30-minute Google Meet — writing the event link back onto the candidate row.
How it flows
- 01
Application lands on the jobs alias
Filter labels it Inbound/Applications and keeps it out of the inbox.
- 02
Watch notification fires
Pub/Sub push; the 7-day watch renewal is on its own schedule.
- 03
Name, role and résumé extracted
From the message headers, body and the PDF attachment.
- 04
Deduped against existing candidates
Match on email: a repeat applicant appends to their row rather than forking it.
- 05
Candidate row created at stage Applied
Résumé attached, source recorded, applied date stamped.
- 06
Moving to Screen checks panel availability
freebusy.query across the panel, in the candidate's timezone; three slots offered.
- 07
Meet booked and written back
Event with a Meet link created, Screen Booked set on the Notion row.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Gmail
Intake for the jobs alias
- 01
Filter and label inbound applications
Settings → See all settings → Filters and Blocked Addresses → Create a new filter. Matches: to:jobs@acme.com has:attachment. Apply the label Inbound/Applications and skip the inbox. Labels are what your watch subscribes to. The intermediate "See all settings" click is the one people miss.
Gmail search used by the filterto:jobs@acme.com has:attachment filename:pdf -label:Inbound/Processed - 02
Create the Pub/Sub topic and let Gmail publish to it
Create the topic in your Cloud project, grant the Pub/Sub Publisher role on it to gmail-api-push@system.gserviceaccount.com, then add a push subscription pointing at your endpoint. Miss the grant and users.watch simply returns an error and intake never starts — it is the single most common reason a first run does nothing. Scopes while you are here: gmail.readonly covers both the watch and the attachment fetch, and you only need gmail.modify if you also write labels back. The Calendar half needs calendar.events plus calendar.freebusy.
- 03
Watch the label via Pub/Sub
users.watch pushes change notifications to the topic you just granted. The subscription expires after 7 days, and Google's own advice is to renew it once a day — a weekly renewal has no margin at all, and intake stops silently on day eight.
POST /gmail/v1/users/me/watch{ "topicName": "projects/acme-hiring/topics/gmail-inbound", "labelIds": ["Label_8823714590213"], "labelFilterBehavior": "include" } - 04
Pull the résumé attachment
users.messages.get with format=full gives you the part ids; then users.messages.attachments.get for the bytes, which arrive base64url-encoded. Keep the message id — it is your dedupe key alongside the email address.
Notion
The candidate database and pipeline stages
- 01
Create the "Candidates" database
Properties: Name (Title), Email (Email), Role (Select), Stage (Status: Applied → Screen → Onsite → Offer → Hired / Rejected), Source (Select: Inbound / Referral / Outbound), Résumé (File), Applied On (Date), Screen Booked (Date), Panel (Person), Notes (Text).
- 02
Dedupe on email before inserting
Query by Email first: POST /v1/data_sources/{data_source_id}/query with Notion-Version: 2026-03-11 (retrieve the database once for its data source id; /v1/databases/{id}/query is deprecated). A repeat applicant should append to the existing row's Notes with the new date and role, not create a second candidate — nothing damages trust in an ATS faster than two rows for one person.
Existing-candidate lookup{ "filter": { "property": "Email", "email": { "equals": "lee@northwind.co" } }, "page_size": 1 } - 03
Upload the résumé straight to Notion
Notion hosts the file for you — no bucket, no signed URLs, no expiry to outlive. Three calls: POST /v1/file_uploads to get an upload id, POST /v1/file_uploads/{id}/send with the bytes as multipart/form-data, then PATCH the page with the property below. The gotcha to design around is the clock: attach the upload within one hour or it expires and you start over. Résumés over 20 MB need mode "multi_part"; free workspaces cap a single file at 5 MiB.
Attach the upload to the Résumé property{ "properties": { "Résumé": { "type": "files", "files": [ { "type": "file_upload", "file_upload": { "id": "a3f1c7e2-59b4-4d80-9c16-2e7ab5d43f08" }, "name": "lee-okafor.pdf" } ] } } }
Google Calendar
Books the screen against real availability
- 01
Check free/busy before offering slots
freebusy.query across the whole panel in the candidate's timezone. Offer three slots, never one — a single proposed time turns scheduling into a week of email. A group address in items expands to its members, so mind two ceilings: groupExpansionMax tops out at 100 identifiers, and free/busy comes back for at most 50 calendars. Calendar does ship a native booking page that would let the candidate self-book, but an appointment schedule publishes one person's availability and does not aggregate a panel's — which is exactly what this step needs, and why it asks freebusy instead.
POST /calendar/v3/freeBusy{ "timeMin": "2026-04-06T09:00:00-04:00", "timeMax": "2026-04-10T18:00:00-04:00", "timeZone": "America/New_York", "items": [{ "id": "rae@acme.com" }, { "id": "eng-panel@acme.com" }] } - 02
Create the event with a Meet link
Pass conferenceDataVersion=1 on the insert or the requested Meet link is silently dropped and you send the candidate an event with no way to join.
POST /calendar/v3/calendars/primary/events?conferenceDataVersion=1{ "summary": "Acme — 30 min screen — Lee Okafor", "start": { "dateTime": "2026-04-07T14:00:00-04:00", "timeZone": "America/New_York" }, "end": { "dateTime": "2026-04-07T14:30:00-04:00", "timeZone": "America/New_York" }, "attendees": [{ "email": "lee@northwind.co" }, { "email": "rae@acme.com" }], "conferenceData": { "createRequest": { "requestId": "screen-lee-okafor-0407", "conferenceSolutionKey": { "type": "hangoutsMeet" } } }, "reminders": { "useDefault": false, "overrides": [{ "method": "email", "minutes": 1440 }] } } - 03
Write the booking back to Notion
Set Screen Booked and put the Meet link in Notes. The candidate row should answer "when is this person talking to us" without opening a calendar.