WorkflowsMCP

Offer approval chain: Notion → Slack → Gmail

Two Slack gates, hiring manager then finance, before a Gmail offer draft gets created.

@workflowsmcpVerified hiringrecruitingatsapproval

Notion automations fire then act — they cannot wait for a click and branch. Stage set to Offer starts this chain; a plain Select (not Stage) holds Pending manager → Pending finance → Approved between gates. Gate 1 resolves the hiring manager from Notion; gate 2 checks a fixed finance allowlist — Slack's click payload carries a user id, never an email, so both compare ids. Only after finance approves is a Gmail draft created, never sent. One yes ends one gate; this needs two yeses in order and remembers which is next.

How it flows

  1. 01

    Stage set to Offer on the candidate row

    Existing recruiting-pipeline behavior, unchanged — this entry only reacts to it.

  2. 02

    Webhook fires; handler re-confirms and opens gate 1

    GET the page, confirm Stage is Offer, then set Offer Approval to Pending manager before any Slack message goes out.

  3. 03

    Hiring manager DMed for gate 1

    Hiring Manager person → email → users.lookupByEmail → DM with Approve/Reject. Click identity is checked against that resolved Slack id.

  4. 04

    Gate 1 Approve opens finance; Reject or silence stops

    A verified Approve sets Offer Approval to Pending finance and DMs a finance approver (allowlist), with gate-1 who/when in the message. Reject writes Rejected — no finance DM, no Gmail draft. Silence leaves Pending manager; there is no native timeout.

  5. 05

    Finance approves gate 2

    Click checked against the allowlist and against the current Offer Approval value so a stale or out-of-order button cannot pass. Sets Offer Approval to Approved.

  6. 06

    Gmail draft created — never sent

    Fires only now, gated strictly on both approvals. The credential could send; the code never calls users.messages.send.

  7. 07

    HR notified with the draft link

    Slack carries the compose link built from the returned draft id. A person reviews it and presses send by hand.

Set up each app

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

Notion

Candidate record and the durable approval-state ledger

  1. 01

    Add two properties to the shared Candidates database

    If the Candidates database from a recruiting-pipeline entry already exists, add these two properties to it rather than creating a second database: Hiring Manager (Person) and Offer Approval (Select: Not started / Pending manager / Pending finance / Approved / Rejected). Do not overload Stage with approval sub-states — Stage is shared pipeline-reporting vocabulary, and a Reject at either gate has no clean place to go inside it. Offer Approval is deliberately a plain Select, not a second Status property: Status forces every value into To-do / In progress / Complete, which does not map cleanly onto "which of two approvers is next," and Notion's docs do not confirm whether a database is capped at one Status-type property — a Select sidesteps that open question rather than betting on it.

  2. 02

    Share the database with your internal connection

    ••• (top right of the database) → Connections → Add connection → your internal connection, with both Read content and Update content on. Miss Update content and the property writes below 403; miss the share entirely and even reads 404 on a database you can plainly see.

  3. 03

    Create the automation on Stage → Offer

    ••• → Automations → New automation → trigger Property edited → Stage → set to Offer → action Send webhook, pointed at your handler. Notion's automation docs list value-specific edit conditions for name, person, number, text, select and relation — Stage is a Status property and status is not named in that list. Spot-check in your own workspace whether "set to Offer" actually filters, or only "any edit to Stage" reaches the webhook; either way, the next step makes the difference harmless. The webhook body carries the page id your handler needs for the GET below — parse it from the payload Notion posts, do not hard-code pages. Notion does not sign Send webhook deliveries; add a custom header (e.g. X-Offer-Approval-Secret) with a random shared value and reject any POST missing it, or the endpoint is open to anyone who guesses the URL.

  4. 04

    Re-confirm Stage server-side, then open the chain

    Have the handler GET /v1/pages/{page_id} with Notion-Version: 2026-03-11 and check properties.Stage.status.name === "Offer" before doing anything else — do not trust that only Offer-transitions reach the webhook. On a confirmed Offer, PATCH Offer Approval to Pending manager. The people array on Hiring Manager carries person.email inline, which is what gate 1 resolves against next.

    GET https://api.notion.com/v1/pages/{page_id} · Notion-Version: 2026-03-11
    // response (excerpt) — gate the chain on Stage before trusting the webhook:
    {
      "properties": {
        "Stage": { "status": { "name": "Offer" } },
        "Hiring Manager": {
          "people": [{ "id": "…", "person": { "email": "rae@acme.com" } }]
        }
      }
    }
  5. 05

    Advance Offer Approval at each gate

    After a verified gate-1 Approve, PATCH Offer Approval to Pending finance; after a verified gate-2 Approve, PATCH it to Approved; a Reject at either gate writes Rejected. Silence leaves the current value alone — there is no native timeout or escalation on either gate.

    PATCH https://api.notion.com/v1/pages/{page_id} · Notion-Version: 2026-03-11
    {
      "properties": {
        "Offer Approval": { "select": { "name": "Pending finance" } }
      }
    }

Slack

Both approval gates, two different recipients

  1. 01

    Create the app, scopes and interactivity

    api.slack.com/apps → Create New App → OAuth & Permissions → Bot Token Scopes below, then Install to Workspace. Interactivity & Shortcuts → toggle Interactivity on → set a Request URL. Slack POSTs each button click there as a form-encoded payload and expects an ack inside 3 seconds — acknowledge first, then touch Notion or Gmail.

    Bot Token Scopes
    chat:write          # post both approval DMs and the outcome updates
    users:read.email    # resolve the hiring manager's Notion email to a Slack id
    im:write            # open a DM with each resolved approver
  2. 02

    Gate 1: resolve the hiring manager dynamically

    Take Hiring Manager.person.email off the page you just re-read, call users.lookupByEmail, conversations.open with that user id, and post the Approve/Reject DM to the channel.id it returns. A block_actions interaction payload's user object carries id, username, team_id and name — no email field — so the click-time check is always an id comparison against the id you resolved before sending, never an email comparison at click time.

  3. 03

    Gate 2: check a fixed finance allowlist instead

    Finance approvers for offers are a small, fixed set of people, not tied to the candidate record, so keep a short Slack user-id allowlist in your own config for this gate — there is no native Slack setting for "who can approve an offer." The interactivity handler checks payload.user.id against this list and silently ignores anyone not on it. When opening the gate-2 DM, pick the recipient from that same allowlist explicitly (a designated primary id, or post one DM per allowlisted user) — the list is both "who may click" and "who gets asked"; do not leave "which finance person is messaged" implicit. Gate 1 is dynamic per candidate and gate 2 is a static list on purpose; swap which gate gets which style if your org's chain runs the other way — this pairing is a policy choice, not a Slack constraint.

  4. 04

    Approval message format, and the ordering guard

    action_id tells the handler which gate and which branch; value carries the Notion page id so no lookup table is needed. Gate 2's message must include who approved gate 1 and when — finance should not be approving blind. Because the two gates DM different people independently, also re-read Offer Approval before acting: a gate-2 click while the record still reads Pending manager is a stale or out-of-order message and must be ignored even when action_id looks right.

    Gate 1 approval DM
    {
      "channel": "<hiring-manager-slack-id-from-email-lookup>",
      "text": "Offer approval needed — gate 1 of 2 (Hiring Manager)",
      "blocks": [
        { "type": "section",
          "text": { "type": "mrkdwn",
            "text": "*Offer approval* — Lee Okafor, Senior Engineer\nCandidate: <https://app.notion.com/p/lee-okafor-2f91c4a7|Notion card>" } },
        { "type": "actions",
          "elements": [
            { "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Approve" },
              "value": "offer:2f91c4a7:gate1:approve", "action_id": "offer_gate1_approve" },
            { "type": "button", "style": "danger", "text": { "type": "plain_text", "text": "Reject" },
              "value": "offer:2f91c4a7:gate1:reject", "action_id": "offer_gate1_reject" }
          ] }
      ]
    }

Gmail

Holds the finished offer as a draft — and only a draft

  1. 01

    Authorise the compose scope, and know what it does not stop

    Use the gmail.compose scope (full value in the snippet). It is the narrowest scope that can create a draft, but be clear about what it is: Google documents it as "Manage drafts and send emails", so it permits sending. Nothing in the credential stops a send. This workflow never sends because the job never calls users.messages.send — that is a property of the code, not of the key, and there is no key that behaves otherwise, because drafting and sending share a scope. Second caveat: gmail.compose is a restricted scope. Against your own mailbox — an internal Workspace app, or an unpublished project with yourself as the test user — that is fine. Publishing it to outside users triggers OAuth app verification and, if you store or transmit the data, a CASA security assessment renewed every year.

    The scope value
    https://www.googleapis.com/auth/gmail.compose
  2. 02

    Gate the draft call on both approvals, not one

    Only create the draft once Offer Approval reads Approved — i.e. after the gate-2 (finance) click, never off gate-1 alone. An offer draft appearing before finance signed off is a confidentiality problem even though it is never sent; treat "fires only on Approved" as a test case, not an incidental ordering detail. The draft body is not assembled by this approval chain: start from a fixed offer template (or a Notion text property you maintain for the role), substitute candidate name/role/comp, and leave the rest for HR to edit in Gmail before Send — do not invent letter copy in the handler. The Gmail API takes a base64url-encoded RFC 2822 message in raw: the -_ alphabet, not +/; build it with CRLF line breaks and keep headers to 7-bit ASCII (RFC 2047-encode anything non-ASCII in Subject).

    POST /gmail/v1/users/me/drafts — fires only after Offer Approval = Approved
    {
      "message": {
        "raw": "VG86IGxlZUBub3J0aHdpbmQuY28NClN1YmplY3Q6IE9mZmVyIC0gU2VuaW9yIEVuZ2luZWVyIGF0IEFjbWUNCg0KSGkgTGVlLCB3ZSdkIGxvdmUgdG8gb2ZmZXIgeW91Li4u"
      }
    }
  3. 03

    Notify HR with the draft link — a human sends

    drafts.create returns an id, not a URL, so build the browser link yourself and post it to HR in Slack. Someone reviews the draft and presses send by hand; nothing about this workflow should call users.messages.send.

    Link to the draft you just created
    https://mail.google.com/mail/u/0/#drafts?compose=r-1938440021785509377