Public
Slack bot: daily Stripe + HubSpot ARR report
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

arr-bot

Daily Stripe + HubSpot ARR digest, posted to Slack (#general).

Required env vars

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).

Where to create credentials

  • Stripehttps://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 format pat-na1-…. If Service Keys aren't enabled on your account yet, a Private App access token with the same scope works identically — same pat-na1- prefix, same Authorization: Bearer … header.
  • Slack — connected via Val Town's dashboard Slack integration. Posts to #general (channel ID hardcoded in slack.ts).
  • Val Townhttps://www.val.town/settings/api → create an API token with Vals: Read and write scope (used by the weekly keep-alive, see below).

HubSpot data model

ARR is summed across closed-won deals in both pipelines:

  • Sales Pipeline (default, stage closedwon)
  • Trial Pipeline (139030823, stage 238829588)

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_yearscustom 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).

Files

  • main.ts — interval handler (cron entry point); builds the report and posts it to Slack
  • report.tsbuildARRReport() assembles the cross-source model (self-serve + enterprise + total)
  • stripe.ts — self-serve ARR: Stripe's own revenue.arr / revenue_growth.arr Analytics metrics (headline + daily new / expansion / contraction / churn), with our Sigma computation (cumulative mrr_change, FX → USD, × 12) as fallback and cross-check; resolveStripeARR() picks which to show
  • sigma.tsrunSigmaQuery() client for the Stripe Sigma Query Run API (submit → poll → download CSV)
  • hubspot.tsfetchEnterpriseARR() sums amount / term_years across both pipelines' closed-won stages, snapshots daily for net change + new-account names
  • usage.tsfetchApiUsageRunRate() pulls the dashboard's "Usage revenue" metric from the Stripe Analytics API; run rate = trailing 28 complete days × 13
  • analytics.ts — shared Stripe Analytics API client: serializes metric queries (Stripe allows one per second per key) and retries 429s
  • slack.tsformatARRReport() builds the Block Kit digest; postToSlack() delivers via Slack Web API
  • query-stripe.ts — manual validation script: prints total ARR + a daily breakdown table
  • query-hubspot.ts — manual validation script: prints per-deal amount/term/ACV + totals so you can spot-check against the source-of-truth sheet
  • query-usage.ts — manual validation script: prints the usage run-rate window vs the Billing → Usage dashboard
  • preview.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). Requires VAL_TOWN_API_KEY. The txt file's content on the val drifts from git by design.

Self-serve ARR: two sources, one answer

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.

Outage behavior

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.

Schedule

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).