WorkflowsMCP

Subscription renewal tracker: Gmail → Notion → Todoist + Calendar

Receipt mail becomes a Notion registry; renewals get a Todoist task and a calendar day.

Google's subscription entries (Play or payments center) are lists you look at — they never parse Gmail receipts or put renew/cancel on your task list and calendar. This watches receipt-like mail, not stars (Personal GTD owns starring), filters recurring from one-off buys, and upserts a Notion registry. Before renewal it creates a Todoist task and an all-day calendar marker; Notion can email a reminder, but that never becomes either. Same skeleton as a company vendor spend audit — this tracks your wallet, not team SaaS.

How it flows

  1. 01

    Daily poll wakes once

    Subscription mail is low volume — one pass a day is enough; no five-minute loop.

  2. 02

    Gmail search returns candidate receipts

    category:purchases OR subject keywords, newer_than:2d, excluding spam/trash and anything already labelled Sub-Captured.

  3. 03

    Bodies are fetched and AI extracts five fields

    Vendor, amount, currency, billing cycle, renewal/due date. Gmail returns base64url payload bodies only — there is no structured receipt API — so amounts and dates are a best-effort first pass; skim the registry before you cancel on them. Recurring language ("per month", "next billing date", "renews on") decides subscription vs one-off buy.

  4. 04

    One-offs are labelled and skipped; subscriptions upsert

    Non-recurring mail still gets Sub-Captured so it is not rejudged. Recurring rows dedupe on vendor and refresh Last seen in the Notion registry.

  5. 05

    Processed mail gets Sub-Captured

    The label — not removing anything from the inbox — is what keeps the next poll idempotent.

  6. 06

    Registry scan finds renewals inside the N-day window

    Query rows where Renewal date is on_or_before the window end, on_or_after today, and Reminder sent is false.

  7. 07

    Todoist task and Calendar marker land as a pair

    One renew-or-cancel task with Notion + receipt links, plus a transparent all-day event on renewal day; then Reminder sent flips true so neither side effect repeats.

Set up each app

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

Gmail

Finds receipt and renewal mail — content signal, never the star

  1. 01

    Enable Gmail API and request read + label scopes

    Google Cloud Console → APIs & Services → Library → enable Gmail API → OAuth consent screen → Credentials → OAuth client ID (Web application). Request gmail.readonly to search and fetch bodies; add gmail.modify only because the poller writes a Sub-Captured label after processing — readonly alone 403s on that write. Poll daily: subscription mail arrives a few times a month, so five-minute polling is wasted quota, and polling keeps setup to one token without a Pub/Sub topic or 7-day watch renewal.

    Scopes
    https://www.googleapis.com/auth/gmail.readonly
    https://www.googleapis.com/auth/gmail.modify
  2. 02

    Pre-run the search and create the Sub-Captured label

    Paste the query below into Gmail's search box and skim the first ~20 hits before you automate anything. category:purchases is Gmail's own purchases bucket (order, dispatch, delivery confirmations) — useful as a narrower net, not a subscription detector; community reports say it sometimes drifts or goes missing on an account, which is why the subject keywords are OR'd in as a fallback, not ANDed. Prefer recall at search time; the AI step later drops one-off buys. Create a Sub-Captured label (sidebar → Labels → Create new label, or POST /gmail/v1/users/me/labels) so processed mail is excluded on the next poll — same idempotent label pattern as Personal GTD's Captured, different label name so the two never collide.

    Gmail search query
    (category:purchases OR subject:(receipt OR invoice OR "payment confirmation" OR "your subscription" OR "auto-renew" OR "has renewed" OR "billing statement" OR "your receipt" OR "order confirmation")) newer_than:2d -in:spam -in:trash -label:Sub-Captured
  3. 03

    A different signal line than Personal GTD starring

    This workflow does not look at whether you starred the mail. It only watches messages that look like subscription receipts — invoices, renewal confirmations, billing statements — on the query above. Personal GTD in this collection fires only on is:starred -label:Captured and turns those into action tasks; the two signal lines are independent, so both workflows can run at once without double-capturing the same message. The one overlap you can create yourself: starring a subscription receipt makes each workflow handle it on its own axis — acceptable duplication, not a conflict. Detection is content-feature based and will miss merchants whose wording never uses those subjects and whose mail never lands in purchases; do not promise lossless coverage of every vendor. When you write Source email / Receipt links, build `https://mail.google.com/mail/u/0/#all/<threadOrMessageId>` the same way Personal GTD does: Google documents no permalink scheme, so treat it as convention. `/u/0` is sign-in order, not a mailbox id — multi-account users must hard-code the index or `authuser`.

Notion

Subscription registry — source of truth for what you still pay for

  1. 01

    Create the "Subscriptions" database

    New page → Table (or /database inside an existing page). Add exactly these properties — names matter because the API writes by name: Vendor (Title), Amount (Number), Currency (Select: USD / EUR / CNY), Billing cycle (Select: Monthly / Annual / Weekly / Unknown), Renewal date (Date), Cancel-by date (Date, optional — useful when annual plans want a cancel decision before the charge), Status (Select: Active / Watch / Cancelled), Source email (URL), Last seen (Date), Reminder sent (Checkbox). Reminder sent is the shared idempotency bit for both the Todoist task and the Calendar marker: one checkbox covers both side effects in the same poll, so you do not maintain two flags. Upsert means: before POST, query the data source for an existing row whose Vendor title equals the extracted merchant; if found, PATCH Amount / Renewal date / Last seen / Source email on that page id instead of creating a duplicate. Dedupe key is the vendor name string from the AI pass — expect occasional collisions on common brands and merge by hand.

    POST /v1/data_sources/{id}/query · Vendor upsert filter · Notion-Version: 2026-03-11
    {
      "filter": {
        "property": "Vendor",
        "title": { "equals": "Netflix" }
      }
    }
  2. 02

    Share the database and parent writes on the data source id

    app.notion.com/developers/connections → Build → Internal connections → Create a new connection → Configuration → copy the Installation access token. Open the database → ••• → Connections → + Add connection → grant Insert content, Read content, and Update content. Retrieve the database once and keep its data_source_id: with Notion-Version 2026-03-11, parenting POST /v1/pages on database_id only accepts the title property — Amount, Renewal date, and the rest require parent.data_source_id. Notion's own database Automations (Page added / Property edited / Every {frequency}) can Send mail to or Send Slack notification to, but their action list cannot create a Todoist task or a Google Calendar event — that is why the renewal landing step lives outside Notion, not because Notion cannot fire a timed reminder at all.

    POST https://api.notion.com/v1/pages · Notion-Version: 2026-03-11
    {
      "parent": { "data_source_id": "<SUBSCRIPTIONS_DATA_SOURCE_ID>" },
      "properties": {
        "Vendor": { "title": [{ "text": { "content": "Netflix" } }] },
        "Amount": { "number": 15.49 },
        "Currency": { "select": { "name": "USD" } },
        "Billing cycle": { "select": { "name": "Monthly" } },
        "Renewal date": { "date": { "start": "2026-09-15" } },
        "Status": { "select": { "name": "Active" } },
        "Source email": { "url": "https://mail.google.com/mail/u/0/#all/18f2c9a4b7e1" },
        "Last seen": { "date": { "start": "2026-08-09" } },
        "Reminder sent": { "checkbox": false }
      }
    }
  3. 03

    Query renewals due inside the reminder window

    Each day, POST /v1/data_sources/{data_source_id}/query with Notion-Version: 2026-03-11. Date range needs both on_or_before (window end) and on_or_after (today) under an and, plus Reminder sent equals false. Replace the two dates with your chosen N-day window; the example below is a 10-day look-ahead from 2026-08-09.

    POST /v1/data_sources/{id}/query · Notion-Version: 2026-03-11
    {
      "filter": {
        "and": [
          { "property": "Renewal date", "date": { "on_or_before": "2026-09-18" } },
          { "property": "Renewal date", "date": { "on_or_after": "2026-08-09" } },
          { "property": "Reminder sent", "checkbox": { "equals": false } }
        ]
      }
    }

Todoist

The renew-or-cancel decision as a real task

  1. 01

    Get your API token and a subscription-renewal label

    Todoist → Settings → Integrations → Developer → copy the API token. Create a subscription-renewal label and pick Inbox or a Subscriptions project as the landing place. Creating tasks needs data:read_write. Use https://api.todoist.com/api/v1/ only — REST v2 and Sync v9 return 410 Gone.

  2. 02

    Create the renewal task with an absolute due string

    When a registry row's Renewal date falls inside your N-day window and Reminder sent is still unchecked, POST a task. content names the decision ("Renew or cancel: Netflix — $15.49/mo"); description carries the Notion row URL and the Gmail receipt permalink. Pass due_string as an absolute "on YYYY-MM-DD" — renewal day is a fixed date, not a relative "tomorrow", and due_string is parsed in the account timezone. Labels: ["subscription-renewal"]. After both this task and the Calendar marker succeed, flip Reminder sent to true on the Notion row so the next scan does not duplicate either. If either side fails, keep Reminder sent=false, and before retrying dedupe Todoist by content/label so a partial success does not spawn a second task.

    POST https://api.todoist.com/api/v1/tasks
    curl -X POST https://api.todoist.com/api/v1/tasks \
      -H "Authorization: Bearer $TODOIST_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "content": "Renew or cancel: Netflix — $15.49/mo",
        "description": "Notion: https://notion.so/<page-id>\nReceipt: https://mail.google.com/mail/u/0/#all/18f2c9a4b7e1",
        "due_string": "on 2026-09-15",
        "labels": ["subscription-renewal"]
      }'

Google Calendar

Marks renewal day on your calendar without blocking busy time

  1. 01

    Enable Calendar API and request events write

    Same Google Cloud project as Gmail is fine. Enable Google Calendar API, then request calendar.events — enough to insert on primary, narrower than freebusy or focus-time scopes other workflows in this collection use. Confirm the write target is calendars/primary; a personal tracker does not need a shared calendar.

    The scope value
    https://www.googleapis.com/auth/calendar.events
  2. 02

    Insert a transparent all-day marker on renewal day

    POST /calendar/v3/calendars/primary/events with start.date / end.date (not dateTime). end.date is exclusive — renewal on 2026-09-15 means end "2026-09-16". Use a plain event, not eventType focusTime: this marks "decide renew or cancel today", it does not defend focus hours. Set transparency to "transparent" so the day does not look busy. Pair this insert with the Todoist task in the same run, then set Reminder sent.

    POST /calendar/v3/calendars/primary/events
    {
      "summary": "Renews today: Netflix — $15.49/mo",
      "description": "Notion: https://notion.so/<page-id>",
      "start": { "date": "2026-09-15" },
      "end": { "date": "2026-09-16" },
      "transparency": "transparent"
    }