Vendor spend audit: Gmail → Notion → Todoist
Shared-inbox receipts into a vendor ledger, then renew-or-cancel tasks in Todoist.
Vendor invoices scatter until nobody knows what renews. Route them into one shared AP inbox first — this reads a single Gmail account. It polls for invoice and renewal mail (body text, not attachment-only), extracts amount and renewal date into Notion, posts a Slack extraction check, and files a renew-or-cancel Todoist task when due. Paid-plan Notion Recurring + Send Slack notification covers the reminder; this adds parsing and Todoist tasks. Personal subscriptions in your own Gmail are a different product — this is company procurement.
How it flows
- 01
Daily poll of the shared AP inbox
users.messages.list with a short newer_than window and invoice/receipt/renewal subject terms — no has:attachment requirement, because most SaaS amounts live in the HTML body.
- 02
Vendor, amount and renewal date extracted
Body text first, attachment bytes only when present. Low-confidence reads (garbled amounts, missing dates, one-time purchase vs recurring subscription) are flagged Needs Review rather than silently written as Confirmed.
- 03
Notion Vendor Contracts row matched or created
Dedupe first on Gmail message id (skip already-seen mail), then match Billing Sender Domain to PATCH or POST Amount, Renewal Date, Extraction Confidence and Last Invoice Email.
- 04
Slack extraction confirmation posted
One message with vendor, amount and confidence so the room can correct a misread within the hour — not the renewal reminder paid Notion already covers natively.
- 05
Daily renewal-window pass on the ledger
Compare each Active row's Renewal Date to today; rows inside the decision window with Renewal Task Filed unchecked move on to task creation.
- 06
Renew-or-cancel task filed in Todoist
Task lands in the shared vendor project, assigned to the Contract Owner, with amount, cycle and links back to Gmail and Notion. Then set Renewal Task Filed so the same window does not spawn duplicate tasks on later days.
- 07
Owner decides; Status closed by hand
After renew or cancel, a human sets Notion Status to Active (and refreshes Renewal Date) or Cancelled — the workflow does not flip that gate on its own.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Gmail
Shared AP inbox that holds vendor invoices and renewals
- 01
Converge vendor mail into one shared inbox first
This workflow authorises a single Gmail account — it cannot search every teammate's mailbox. Before anything else, pick the inbox you will connect (a shared AP alias such as ap@company.com, or the finance owner's mailbox) and route vendor invoices, receipts and renewals there: forward from personal inboxes, update vendor billing contacts, or set Workspace routing rules so new invoices land in that one place. Skip this and the first run only sees whatever that one account already holds.
- 02
Authorise read-only access on that inbox
Create an OAuth client against the shared AP account and grant only `gmail.readonly` — enough to search, read full messages and (when present) pull attachment bytes. Prefer storing the Gmail message id on the Notion row for dedupe rather than writing labels back; that keeps you off `gmail.modify`. Polling with a short `newer_than` window is the default path here — vendor receipts are not minute-critical, and it avoids standing up a Cloud Pub/Sub project just for `users.watch`.
Scope valuehttps://www.googleapis.com/auth/gmail.readonly - 03
Optionally label inbound vendor mail for humans
Settings → See all settings → Filters and Blocked Addresses → Create a new filter. A subject filter helps people spot what the job will consider; it is not a hard dependency of the parser. Do not require `has:attachment` — most SaaS renewals (Figma, Slack, Notion itself and similar monthly tools) put the amount in the HTML body and never attach a PDF. Attachments still help when they exist (AWS invoices, some annual contracts), but body text is the primary source.
Optional Gmail filter querysubject:(invoice OR receipt OR renewal OR "your subscription") - 04
Poll for recent candidate receipts
Once a day, list messages with the same search operators the Gmail UI accepts (`q` on `users.messages.list`). Then `users.messages.get` with `format=full` for the body (and attachment part ids when present). Keep each message id — it is the Notion dedupe key. Todoist-for-Gmail is a different product surface: a human clicks one mail into one task; it does not batch-scan, extract amounts or write Notion.
GET /gmail/v1/users/me/messagescurl -s -G "https://gmail.googleapis.com/gmail/v1/users/me/messages" \ -H "Authorization: Bearer $GMAIL_ACCESS_TOKEN" \ --data-urlencode "q=newer_than:1d subject:(invoice OR receipt OR renewal OR \"subscription\")"
Notion
Vendor Contracts ledger — amounts, renewal dates, owners
- 01
Create the "Vendor Contracts" database
Create a new page → Table. Properties (names matter; the API writes by name): Vendor (Title), Billing Sender Domain (Text — the invoice From domain, used to match repeats), Contract Owner (Person — the internal owner, later mapped into Todoist), Amount (Number, currency format), Billing Cycle (Select: Monthly / Annual / Other), Renewal Date (Date), Cancellation Deadline (Date — keep separate from Renewal Date when the contract has an early-cancel window), Status (Status: Active / Needs Review / Renew / Cancel / Cancelled), Extraction Confidence (Select: Confirmed / Needs Review), Last Invoice (Files), Last Invoice Email (URL — Gmail permalink or message id for audit), Gmail Message Id (Text — idempotent key for already-seen mail), Renewal Task Filed (Checkbox, default unchecked — prevents duplicate renew-or-cancel tasks), Notes (Text).
- 02
Connect an internal integration to the database
app.notion.com/developers/connections → Build → Internal connections → Create a new connection → copy the Installation access token. Open the Vendor Contracts database → ••• → Connections → + Add connection → your connection → confirm. A database you can see still 404s until that last click. Retrieve the database once (`GET /v1/databases/{database_id}`) and keep its `data_source_id` — queries go to `/v1/data_sources/{data_source_id}/query` with `Notion-Version: 2026-03-11`; `/v1/databases/{id}/query` is deprecated.
- 03
Know what paid Notion already covers — and what it does not
On a paid Notion plan, database automations offer a Recurring trigger (every day / week / month at a set time) plus a Send Slack notification action. Wired to Renewal Date, that pair alone can remind a budget owner that a vendor is coming due — Free plan users can create Slack notification automations but not other kinds, so Recurring itself is a paid-plan feature. Notion has no native "Create Todoist task" action (Send webhook only reaches Todoist if you build the other end yourself). Keep this workflow for Gmail receipt parsing into the ledger and for the Todoist renew-or-cancel task; if you only needed the reminder, stop at the native automation on a paid Notion plan.
- 04
Dedupe by sender domain, then write the extraction
Before inserting, query by Billing Sender Domain. Also keep a Text property **Gmail Message Id** (or parse it from Last Invoice Email). Before any write, query that property for the current message id and skip if a row already carries it — that is the idempotent key that lets you stay on `gmail.readonly` without applying a Vendor/Processed label. Billing Sender Domain matching still decides whether to PATCH an existing vendor row or POST a new one when the message is new. On a match, PATCH the existing page with the new Amount, Renewal Date, Extraction Confidence, Status and Last Invoice Email; otherwise POST a new page. When the model is unsure — wrong-looking amounts, missing dates, or a one-time purchase that might not be a recurring vendor — set Extraction Confidence and Status to Needs Review rather than silently filling numbers. Resolve Contract Owner via `GET /v1/users/{user_id}` to the person's email for the Todoist assignee map later. If a PDF attachment exists, the three-step file upload (`POST /v1/file_uploads` → send → PATCH Last Invoice) is optional enrichment, not required for the row to exist.
Query by sender domain, then PATCH extraction fields · Notion-Version: 2026-03-11POST /v1/data_sources/{data_source_id}/query { "filter": { "property": "Billing Sender Domain", "rich_text": { "equals": "figma.com" } }, "page_size": 1 } PATCH /v1/pages/{page_id} { "properties": { "Amount": { "number": 1200 }, "Renewal Date": { "date": { "start": "2026-09-15" } }, "Extraction Confidence": { "select": { "name": "Confirmed" } }, "Status": { "status": { "name": "Active" } }, "Last Invoice Email": { "url": "https://mail.google.com/mail/u/0/#inbox/18d3f2a1b2c3d4e5" } } }
Slack
Extraction confirmation — catch a bad read within the hour
- 01
Create the app and grant chat:write
api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes: `chat:write` (post the confirmation into a fixed finance channel). Install/reinstall to the workspace and `/invite` the bot into that channel. This is not the renewal reminder — paid Notion Recurring + Send Slack notification already covers that leg; Slack here carries extraction confidence, which the native Notion template does not.
- 02
Post one confirmation per extraction batch
After each Gmail → Notion write, `chat.postMessage` a short summary: vendor, amount, billing cycle and whether confidence was Confirmed or Needs Review. High confidence is a heads-up; low confidence asks a human to open the Notion row (and the Last Invoice Email link) before anyone acts on the numbers. Read `ok` from the response body — Slack returns HTTP 200 with `{"ok":false,...}` on auth and scope failures.
chat.postMessagecurl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json; charset=utf-8" \ -d '{ "channel": "#finance-ops", "text": "Figma renewal $1,200/year (confidence: Confirmed) filed. One mail from unknown-billing@vendor.io failed amount extraction — Status Needs Review, please check." }'
Todoist
Renew-or-cancel decision task for the contract owner
- 01
Get an API token and a shared project
Todoist Settings → Integrations → Developer → copy the personal API token, or register an OAuth app. Create (or pick) a shared project for vendor decisions — e.g. "Vendor Renewals" — and copy its `project_id` from the project URL. Call `/api/v1/` only: REST v2 returns 410 Gone.
- 02
Confirm each Contract Owner is a collaborator
Assigning with `assignee_id` only works when that person is a collaborator on the target project. Pull the roster with a real token (`GET /api/v1/projects/{project_id}/collaborators`) and map Contract Owner email → `user_id`. Call it authenticated — an unauthenticated request can return an empty 200, which looks like "nobody is on the project" rather than "you forgot the header".
GET /api/v1/projects/{project_id}/collaboratorscurl -s "https://api.todoist.com/api/v1/projects/6X6WMMqgq2PWxjCX/collaborators" \ -H "Authorization: Bearer $TODOIST_TOKEN" - 03
File the renew-or-cancel task when the window opens
When the daily Notion pass finds a row whose Renewal Date falls inside your decision window (for example thirty days out), POST a task with the vendor, amount, billing cycle, Gmail link and Notion row URL in `description`, assign it to the Contract Owner, and leave Status on the Notion row for a human to flip to Active (renewed — refresh Renewal Date) or Cancelled after they decide. Only POST when Renewal Task Filed is unchecked. After a successful create, PATCH that checkbox true on the Notion row so the next daily window pass does not file another task for the same renewal. Stay inside Todoist's documented request limits; a few dozen vendors per run is well within ordinary personal-token use.
POST /api/v1/taskscurl -X POST "https://api.todoist.com/api/v1/tasks" \ -H "Authorization: Bearer $TODOIST_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "content": "Decide: renew or cancel Figma? (due 2026-09-15)", "description": "Vendor: Figma · Amount: $1,200/year · Billing Cycle: Annual · Last invoice: https://mail.google.com/mail/u/0/#inbox/18d3f2a1b2c3d4e5\nNotion row: https://www.notion.so/vendor-contracts/figma-xxxx", "project_id": "6X6WMMqgq2PWxjCX", "due_string": "in 5 days", "priority": 3, "assignee_id": "158111168" }'