WorkflowsMCP

Post-interview feedback: Calendar → Slack → Typeform → Notion

When an interview ends, DM each interviewer a rating link and log replies in Notion.

@workflowsmcpVerified hiringrecruitinginterviewscandidate-experience

Feedback dies once interviewers leave the room. Calendar reminders and Slack's Calendar app only ping before a meeting starts — nothing fires when it ends. This polls the booking calendar, keeps only events whose end has actually passed, and DMs each panelist a Typeform rating link. Scores land on the candidate's Candidates row — reuse your hiring pipeline's database, or create a lean one. Interviewers are matched by company domain, so a coordinator cc'd for visibility can get a false survey; a manual exclude list covers that.

How it flows

  1. 01

    Poll the booking calendar every 5 minutes

    timeMin=now−15min, timeMax=now, singleEvents=true, orderBy=startTime — then a client-side filter keeps only events where end.dateTime <= now. The API window alone would also match an interview still in progress; the client-side check is what actually means "ended." All-day entries are skipped.

  2. 02

    Dedupe by event id

    Track processed event ids locally, expiring past the 15-minute lookback — the read-only Calendar scope means nothing can be written back onto the event to mark it handled.

  3. 03

    Attendees split into candidate and interviewer(s)

    The one external-domain attendee is the candidate. Internal-domain attendees whose resource field isn't true are interviewers — never trust organizer or self for this, since the polled calendar belongs to the coordinator/booking flow, not any interviewer. Domain-match alone has a known false positive: a coordinator cc'd on the invite for tracking, not to interview, looks identical to a real interviewer. Drop anyone on the optional exclude list. An event with more than one external domain, or with no internal attendees, is skipped and flagged for manual review instead of guessed at.

  4. 04

    Candidate matched to their Notion row

    The candidate's email queries the Candidates database; the returned page id becomes the Typeform candidate_id. If the query returns no page — candidate never entered in the hiring pipeline, or the lean table was not seeded — skip the event and flag it for manual review; do not create a Candidates row from a calendar invite alone.

  5. 05

    Each interviewer DMed their rating link

    users.lookupByEmail resolves a Slack id per interviewer; chat.postMessage DMs them the Typeform link with candidate_id and interviewer_slack_id attached as URL parameters. An interviewer with no Slack account is skipped and tallied, not retried forever.

  6. 06

    Interviewer submits the form

    form_response fires; signature verified, the rating and notes read by ref, the two identity values read from hidden by name.

  7. 07

    Feedback appended to the candidate row

    The existing Interview Feedback text is read first, then PATCHed with the new line added — one line per interviewer per interview, never overwriting a feedback entry another interviewer just submitted.

Set up each app

Work through these in order — later apps usually need a token or an id from an earlier one.

Google Calendar

Trigger — spots the moment an interview has actually ended

  1. 01

    Enable the Calendar API and configure OAuth consent

    Google Cloud Console → APIs & Services → Library → search "Google Calendar API" → Enable. Then APIs & Services → OAuth consent screen → set App name and User type (Internal only works inside a Workspace domain; External + Testing mode is enough for a team running this on itself).

  2. 02

    Create a Web application OAuth client

    Credentials → Create Credentials → OAuth client ID → Web application, not Desktop app — the Desktop quickstart only covers a one-off interactive login, not a refresh token an unattended poller can renew silently. Build the authorization URL with access_type=offline, or the access token has no way to renew once it expires.

  3. 03

    Scope read-only, and poll the booking calendar — not each interviewer's own

    Scope to calendar.events.readonly. Point the poller at the single calendar your interview-booking flow writes events to (the recruiting coordinator's or ATS's own calendar), not at every interviewer's personal calendar — enumerating those would need Workspace domain-wide delegation, an admin-only setup step this workflow doesn't budget for. State that calendar's owner plainly when you configure the poller; defaulting to "my own primary calendar" by habit is the usual misconfiguration.

    The scope value
    https://www.googleapis.com/auth/calendar.events.readonly
  4. 04

    Poll for ended interviews — do not reverse-copy a "starting soon" window

    timeMin is an exclusive lower bound on an event's end time; timeMax is an exclusive upper bound on its start time. A "starting soon" poller (timeMin=now, timeMax=now+30min) works because that pair already means "still ongoing or about to start." Reversing the same shape to timeMin=now−15min&timeMax=now is not enough for "ended": it still returns any event whose end is after 15 minutes ago and whose start is before now — including a two-hour onsite that started 20 minutes ago and has not finished. After every fetch, keep only events where end.dateTime <= now; that client-side check is what "ended" actually means. Poll every 5 minutes with singleEvents=true before orderBy=startTime, skip all-day events (they carry a date field instead of dateTime), and optionally keep an exclude list of coordinator emails that should never be surveyed even when their domain matches.

    GET /calendar/v3/calendars/primary/events
    GET https://www.googleapis.com/calendar/v3/calendars/primary/events
      ?timeMin=2026-08-09T13:45:00-04:00
      &timeMax=2026-08-09T14:00:00-04:00
      &singleEvents=true
      &orderBy=startTime
      &maxResults=10
    Authorization: Bearer {access_token}
    
    # Window narrows candidates. After the fetch, keep only
    # results where end.dateTime <= now — the API alone does not.

Slack

DMs each interviewer their rating link

  1. 01

    Create the app with two Bot Token Scopes

    api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes: chat:write and users:read.email. Install to Workspace and copy the xoxb- token. chat.postMessage's channel parameter accepts a Slack user id directly and opens the DM itself, so there's no im:write scope or conversations.open call to add — every message here is a DM, never a channel post.

  2. 02

    Resolve each interviewer to a Slack id

    users.lookupByEmail on each identified interviewer's company email. An unmatched email — someone who isn't a Slack workspace member, or a mis-typed calendar invite — returns users_not_found; skip that one DM and keep a tally rather than failing the whole run. Slack answers every call, success or failure, with HTTP 200 — check the response body's ok field, not the status code.

  3. 03

    Send the DM with the finished Typeform link

    The link is built by the workflow itself, not through any merge-tag templating layer: take the base Typeform URL and append the candidate_id and interviewer_slack_id values already resolved in earlier steps as URL parameters, then drop the finished link into the DM text.

    POST https://slack.com/api/chat.postMessage
    {
      "channel": "U024BE7LH",
      "text": "Feedback needed: Lee Okafor interview (Backend Eng — Onsite)",
      "blocks": [
        { "type": "section", "text": { "type": "mrkdwn",
          "text": "Your interview with *Lee Okafor* just wrapped — 2 minutes for feedback while it's fresh:\n<https://acme.typeform.com/to/iF33dB4ck?candidate_id=2a1f7c9e-...&interviewer_slack_id=U024BE7LH|Rate the candidate>" } }
      ]
    }

Typeform

Carries the rating question and the candidate/interviewer identity

  1. 01

    Build the rating form

    One Opinion Scale question, "How would you rate this candidate?" (1-5), plus one open-text "Notes" field for the write-up. Give both a stable ref via the question's Settings menu → Block references, so a later reorder of the form doesn't scramble which answer is which.

  2. 02

    Add URL parameters for the candidate and interviewer identity

    Form editor → Settings → URL parameters (Typeform's current name for what it used to call Hidden Fields) → add candidate_id and interviewer_slack_id. The workflow already knows both values in memory when it builds the DM — the Notion candidate page id and the Slack id just resolved — so it string-concatenates the finished link itself; no merge-tag templating layer is involved. Every submission echoes both values back in the webhook payload's hidden object.

  3. 03

    Register and sign the webhook

    Form → Connect → Webhooks → Add a webhook → your endpoint → Save, then toggle it on — it defaults to off. The endpoint must answer over https within 30 seconds or Typeform retries and eventually disables it. Set a signing secret and verify the Typeform-Signature header (format sha256=<base64 HMAC-SHA256 of the raw body>) — the endpoint is public and will get probed.

  4. 04

    (Optional) Personal Access Token for a reconciliation pull

    Not required for the core flow, which is webhook-driven. If you add a periodic pull to catch missed webhook deliveries, create the token at Account → Personal tokens → Generate a new token, scoped to responses:read only — narrower than granting webhooks:write, which this setup doesn't need since the webhook itself was registered through the UI above, not the API.

  5. 05

    Read the payload by ref and by name

    The webhook fires form_response with the rating and notes tagged by the refs set above, and the two identity values in the hidden object, tagged by name.

    form_response payload (trimmed)
    {
      "event_id": "01JXQIFEEDBACK2",
      "event_type": "form_response",
      "form_response": {
        "form_id": "iF33dB4ck",
        "token": "b7c94fd12e",
        "submitted_at": "2026-08-09T15:10:44Z",
        "hidden": { "candidate_id": "2a1f7c9e-...", "interviewer_slack_id": "U024BE7LH" },
        "definition": {
          "fields": [{ "id": "rate01", "type": "opinion_scale", "ref": "candidate_rating" }]
        },
        "answers": [
          { "type": "number", "number": 4, "field": { "ref": "candidate_rating" } },
          { "type": "text", "text": "Strong on system design, light on communication.", "field": { "ref": "feedback_notes" } }
        ]
      }
    }

Notion

Candidate profile — where feedback accumulates

  1. 01

    Reuse the Candidates database — or create a lean standalone one

    Prefer the same Candidates database your hiring pipeline already keeps (Name, Email, Role, Stage, Source, Résumé, Applied On, Screen Booked, Panel, Notes). Add one property this workflow needs: Interview Feedback (Text). Do not fork a second candidate database for the same people — one person should have one row. No pipeline database yet? Create a lean standalone Candidates table with Name (Title), Email (Email), and Interview Feedback (Text); that path is fully self-contained. Create the connection at app.notion.com/developers/connections if you don't already have one, then open the database → ••• → Connections → Add connection. Grant both Read content and Update content — this workflow matches by email and appends feedback, so a read-only grant is not enough.

  2. 02

    Match the candidate by Email

    Query with POST /v1/data_sources/{data_source_id}/query (Notion-Version: 2026-03-11; retrieve the database once for its data source id). Filter on the Email property using the external-domain attendee from the calendar event. The returned page id becomes the Typeform candidate_id URL parameter — resolve to an internal id once, then thread it through.

    Existing-candidate lookup
    {
      "filter": { "property": "Email", "email": { "equals": "lee@northwind.co" } },
      "page_size": 1
    }
  3. 03

    Append feedback — never overwrite

    PATCH replaces a rich_text property's value wholesale — it does not append. First GET the page to read the current Interview Feedback value, then PATCH with that text plus one new line added, per submission. Skipping the read-before-write matters here specifically: several interviewers submit for the same candidate close together, and a blind PATCH from a later submission can silently drop an earlier one's feedback.

    PATCH https://api.notion.com/v1/pages/{candidate_page_id} · Notion-Version: 2026-03-11
    {
      "properties": {
        "Interview Feedback": {
          "rich_text": [
            // content below is AFTER concatenating prior Interview Feedback + the new line
            { "type": "text", "text": { "content": "2026-08-09 — Rae Okafor rated 4/5: Strong on system design, light on communication.\n" } }
          ]
        }
      }
    }