Weekly investor update draft
Stripe metrics plus Notion highlights, assembled into a Gmail draft you edit and send.
Investor updates slip because assembling the numbers is the boring part. This workflow pulls the week's revenue movement from Stripe with a read-only key, collects highlights and lowlights from a Notion database, drafts the update in your own voice, and leaves it sitting in Gmail as a draft. It never sends — a machine-written note to your board going out unread is a failure mode worth designing against.
How it flows
- 01
Monday 07:00 trigger
Early enough that the draft is waiting before the week starts.
- 02
Stripe metrics pulled
MRR, new, expansion and churned, week over week, with a read-only restricted key.
- 03
Highlights pulled from Notion
Only rows flagged "Share with investors" from the last seven days.
- 04
Draft assembled in your voice
Numbers first, then wins, then lowlights, then a specific ask. Lowlights are never dropped.
- 05
Gmail draft created
Draft only. The job never calls the send endpoint — that, and not the scope, is what stops it.
- 06
You edit and press send
The reminder links straight to the draft. Nothing reaches investors unread.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Stripe
The revenue numbers, read-only
- 01
Create a restricted, read-only key
Developers → API keys → Create restricted key. Grant Read on Subscriptions, Charges, Customers and Invoices; leave every Write permission off. A reporting job has no business holding a key that can refund — and that is Stripe's own guidance, which recommends restricted keys over secret ones especially when the key is being handed to an AI agent.
- 02
Take the numbers from Billing analytics first
Stripe already computes what this update needs. Billing → Analytics decomposes MRR growth into New, Reactivation, Expansion, Contraction and Churn MRR, and it handles the edge cases hand-rolled normalisation gets wrong: trials, past_due, FX, discounts. Two exports do the job — "Customer MRR changes" is a log of every MRR change per customer, "Subscription metrics summary" is the MRR roll-forward. Pull those as CSV and you can skip both API steps below. State the one honest caveat in the note itself: the figures land 24 to 48 hours behind unless you turn on Faster Data Updates, so a Monday 07:00 job is reading through Saturday.
- 03
Fallback: compute MRR from the subscription list
For accounts without Billing analytics. List active subscriptions and normalise yearly plans yourself — Stripe's own definition is the yearly amount divided by 12, monthly as-is, all in minor units. Paginate: the default page is 10 and the silent truncation looks exactly like churn. There is nothing to expand here — the nested price on each item already carries unit_amount and recurring.interval in the default response.
Active subscriptionscurl -G https://api.stripe.com/v1/subscriptions \ -u "$STRIPE_RESTRICTED_KEY:" \ -d status=active \ -d limit=100 # normalise: yearly amount / 12, monthly amount as-is, all in minor units - 04
Fallback: get the week-over-week delta from events
There is no canceled_at filter on the subscription list, and status=canceled returns every cancellation you have ever had, not a recent window. The documented way to ask "what changed in this window" is the events endpoint. Note what a created-plus-canceled diff can never tell you: an upgrade is neither, so expansion and contraction only surface in the updated event, by diffing previous_attributes. That matters, because "MRR went up" is not a board update and "up 4.2k, of which 3.1k is expansion" is.
What changed in the last 7 days# types takes up to 20 event names; events are retained for 30 days. # created[gte] = 2026-03-04T00:00:00Z, the same window the Notion query uses. curl -G https://api.stripe.com/v1/events \ -u "$STRIPE_RESTRICTED_KEY:" \ -d "types[]=customer.subscription.created" \ -d "types[]=customer.subscription.deleted" \ -d "types[]=customer.subscription.updated" \ -d "created[gte]=1772582400" \ -d limit=100 # created -> new MRR; deleted -> churned MRR; # updated + previous_attributes.items -> expansion / contraction.
Notion
The qualitative half of the update
- 01
Keep a "Company Highlights" database
Properties: Name (Title), Date (Date), Type (Select: Win / Lowlight / Hire / Ask), Detail (Text), Share with investors (Checkbox). The checkbox matters — not everything internal belongs in a board note.
- 02
Query the last seven days
Share the database with your internal connection first (••• → Connections → Add connection) and keep "read content" on, or this 404s on a database you can plainly see. Then 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), filtering on the checkbox and the date window. Grouping by Type in your prompt keeps the draft from turning every item into a win.
Data source query filter{ "filter": { "and": [ { "property": "Share with investors", "checkbox": { "equals": true } }, { "property": "Date", "date": { "on_or_after": "2026-03-04" } } ] }, "sorts": [{ "property": "Type", "direction": "ascending" }] }
Gmail
Holds the draft — and only the draft
- 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 valuehttps://www.googleapis.com/auth/gmail.compose - 02
Create the draft
The Gmail API takes a base64url-encoded RFC 2822 message in raw: the -_ alphabet, not +/. Most helpers do it for you — urlsafe_b64encode in Python, toString("base64url") in Node. Build the message itself with CRLF line breaks and keep the headers to 7-bit ASCII; a raw em dash in a Subject line needs RFC 2047 encoding, so use a plain hyphen unless you are ready to encode it.
POST /gmail/v1/users/me/drafts{ "message": { "raw": "VG86IGludmVzdG9yc0BhY21lLmNvbQ0KU3ViamVjdDogQWNtZSAtIHdlZWsgb2YgMTEgTWFyDQoNCk1SUiAkNDIuMWsgKCs0LjJrKSwgb2Ygd2hpY2ggJDMuMWsgaXMgZXhwYW5zaW9uLi4u" } } - 03
Nudge yourself, do not automate the send
Have the job run Monday 07:00 and post yourself a Slack message, or an email, linking straight to the draft. The drafts create call returns an id rather than a URL, so build the browser link from it in the form below. The five minutes you spend editing is the entire value of the update.
Link to the draft you just createdhttps://mail.google.com/mail/u/0/#drafts?compose=r-1938440021785509377