Daily Stripe + HubSpot ARR digest, posted to Slack (#general).
Set these in the Val Town env var editor (one click per link):
👉 Add STRIPE_SECRET_KEY here: https://www.val.town/x/consensus/arr-bot/environment-variables?key=STRIPE_SECRET_KEY
👉 Add HUBSPOT_TOKEN here: https://www.val.town/x/consensus/arr-bot/environment-variables?key=HUBSPOT_TOKEN
👉 Add VAL_TOWN_API_KEY here: https://www.val.town/x/consensus/arr-bot/environment-variables?key=VAL_TOWN_API_KEY
SLACK_TOKEN is auto-configured by Val Town's Slack integration (connect via the val dashboard).
- Stripe — https://dashboard.stripe.com/apikeys → create a Restricted Key. Needs
reporting_write+sigma_api_write(shown as Sigma + Sigma API) plus Analytics read (for the usage run-rate metric), and an active Sigma subscription. ARR is read from Sigma, not the Subscriptions REST endpoint, so it matches the dashboard. - HubSpot — app.hubspot.com → ⚙️ Settings → Integrations → Service Keys (public beta as of Feb 2026; this replaced Private Apps for data-only integrations). Create a key with scope
crm.objects.deals.read. Token formatpat-na1-…. If Service Keys aren't enabled on your account yet, a Private App access token with the same scope works identically — samepat-na1-prefix, sameAuthorization: Bearer …header. - Slack — connected via Val Town's dashboard Slack integration. Posts to
#general(channel ID hardcoded inslack.ts). - Val Town — https://www.val.town/settings/api → create an API token with Vals: Read and write scope (used by the weekly keep-alive, see below).
ARR is summed across closed-won deals in both pipelines:
- Sales Pipeline (
default, stageclosedwon) - Trial Pipeline (
139030823, stage238829588)
HubSpot's built-in hs_arr / hs_acv / hs_tcv properties aren't populated on these deals (require Sales Hub Pro), so per-deal ACV is derived as amount / term_years:
amount— built-in. For single-year deals this is the ACV; for multi-year deals it stores TCV.term_years— custom property maintained by sales (Karthik/Eric). Required for multi-year deals to report accurate ARR. Blank/0 is treated as 1-year (sensible default).
If term_years isn't yet created in HubSpot, the digest will overcount multi-year deals as TCV. See query-hubspot.ts for a per-deal validation dump.
Net change for the enterprise segment uses daily snapshots stored in Val Town's per-val SQLite (enterprise_arr_daily table, keyed by day).
main.ts— interval handler (cron entry point); builds the report and posts it to Slackreport.ts—buildARRReport()assembles the cross-source model (self-serve + enterprise + total)stripe.ts— self-serve ARR: Stripe's ownrevenue.arr/revenue_growth.arrAnalytics metrics (headline + daily new / expansion / contraction / churn), with our Sigma computation (cumulativemrr_change, FX → USD, × 12) as fallback and cross-check;resolveStripeARR()picks which to showsigma.ts—runSigmaQuery()client for the Stripe Sigma Query Run API (submit → poll → download CSV)hubspot.ts—fetchEnterpriseARR()sumsamount / term_yearsacross both pipelines' closed-won stages, snapshots daily for net change + new-account namesusage.ts—fetchApiUsageRunRate()pulls the dashboard's "Usage revenue" metric from the Stripe Analytics API; run rate = trailing 28 complete days × 13analytics.ts— shared Stripe Analytics API client: serializes metric queries (Stripe allows one per second per key) and retries 429sslack.ts—formatARRReport()builds the Block Kit digest;postToSlack()delivers via Slack Web APIquery-stripe.ts— manual validation script: prints total ARR + a daily breakdown tablequery-hubspot.ts— manual validation script: prints per-deal amount/term/ACV + totals so you can spot-check against the source-of-truth sheetquery-usage.ts— manual validation script: prints the usage run-rate window vs the Billing → Usage dashboardpreview.ts— renders the Slack message without posting (dry run)keepalive.ts+keepalive.txt— weekly cron that rewrites the txt file via the Val Town REST API; the authenticated edit counts as account activity, preventing the free-tier ~14-day inactivity pause that silently stops all crons (bit us Jul 31–Aug 1). RequiresVAL_TOWN_API_KEY. The txt file's content on the val drifts from git by design.
The headline comes from Stripe's own revenue.arr metric (the dashboard's
ARR) and the daily breakdown from revenue_growth.arr. Our Sigma computation
(cumulative mrr_change over subscription_item_change_events_v2_beta, FX →
USD, × 12) runs alongside it every time. resolveStripeARR() in stripe.ts
decides what to show:
- both agree within 5% → Stripe's metric
- the metric errors → Sigma, with a "⚠️ … computed from Sigma" note
- they disagree → whichever is within 5% of the last validated value (kept in SQLite), with a note naming both numbers; if neither is, the run refuses the number and the stale-value fallback below kicks in with a warning
Why both: on 2026-09-09 Stripe backfilled TRIAL_START / TRIAL_END /
CONVERT_TRIAL rows into the Sigma table; CONVERT_TRIAL double-counts MRR
that ACTIVE_START already carries, which inflated the naive sum by ~73% for
two digests. In Aug 2026 the Analytics API's usage metric was broken
server-side for a month. Neither source alone has earned blind trust. The Sigma
queries are also restricted to an explicit ACTIVE_* event-type allow-list.
Stripe allows one Analytics metric query per second per key; analytics.ts
serializes all of them and retries 429s.
Each source fetch fails independently. A failing segment shows its last good headline number (cached in SQLite) with a "⚠️ Source unavailable or failed validation" note and "—" for net change; the Total still sums using the cached value. A segment that has never succeeded shows "—" and blanks the Total.
Interval is set in the val's interval settings (configured separately from file content). Currently: daily at 01:00 UTC (6pm PT). Note: Val Town cron is UTC-only and does not adjust for DST, so this is 6pm during PDT (summer) and 5pm during PST (winter).