Earnings
Lesson 10 · about 15 minutes · data that is pushed to us: Quartr webhooks in, three read surfaces out, and the nightly move from live to historical.
Everything in lessons 7 to 9 polls a vendor. Earnings is the one domain where the vendor calls us. That inverts the trust problem: instead of authenticating outbound with a key we hold, we must prove an inbound request really came from Quartr and has not been replayed. The rest of the domain is read paths over what the webhook wrote, plus EPS and revenue numbers that come from Massive on a schedule.
Inbound: the Quartr webhook
/functions/v1/quartr-webhook-handling with three headers: webhook-id, webhook-timestamp, webhook-signature. Only POST is accepted; OPTIONS gets a CORS response. quartr-webhook-handling/index.tsx-forwarded-for, x-real-ip or cf-connecting-ip. security/rate-limiter.tswhsec_-prefixed secret from QUARTR_WEBHOOK_SECRET. The timestamp must be within CONFIG.TIMESTAMP_TOLERANCE of now, and comparison is constant-time. Every failure returns false, never throws, so nothing about why leaks to the caller. security/signature-verification.tswebhook-id. The set is in-memory per isolate, capped and halved when full. security/replay-protection.tsawait req.text() once, verify it, then parse. Any refactor that parses first breaks every webhook silently with a 401.The replay set lives in one isolate's memory. A cold start or a second instance forgets it. The file says so and names the upgrade (Redis or a table). It is a known ceiling, not a bug, because the timestamp tolerance bounds the replay window anyway.
What the webhook writes
| Table | Written by | Purpose |
|---|---|---|
Companies | company processor | the company universe; other domains join to quartr_company_id |
Events | event processor | scheduled calls with event_date, fiscal year and period |
Earnings_Calls_Live_Audio | live audio processor | stream state and URL while a call is live |
Earnings_Calls_Live_Transcripts | live transcript processor | running transcript while a call is live |
Historical_Events, Historical_Audio, Historical_Transcripts | private.transfer_archived_nightly() | one row per ticker after a call archives; live tables stay small |
The nightly transfer is a SECURITY DEFINER function with search_path = '' and a 30-minute statement timeout. It skips rows with null tickers, refuses to delete a company's live rows if any of its tickers is invalid, and counts what it skipped. Updating Events.event_date propagates to live audio and transcripts by trigger. migrations/20251013185526_fix_transfer_nightly_function.sql · 20260105220000_sync_event_date_to_audio.sql
Outbound: three read surfaces
| Function | Auth | Routing | Reads |
|---|---|---|---|
earnings | PUBLISHABLE_KEY via apikey or bearer; the secret key is explicitly rejected | URLPattern('/earnings/:resourceType') against an enum of six resource types | live and historic audio/transcript, schedule, calendar |
earnings-cache | user JWT, forwarded to the client so RLS applies | single route | get_earnings_cache_for_user() RPC; per-user Upstash Redis rate limit of one call per 30 s |
stocks/details | public | see lesson 6 | Earnings_Metrics for the quarters array and next date |
Two routing styles live side by side. stocks takes the last path segment; earnings uses the web-standard URLPattern. Both are fine; know which one you are in before adding a route. earnings/index.ts · earnings/types.ts:10
PUBLISHABLE_KEY and not SUPABASE_ANON_KEY. The earnings function is an external API for the Reddit integration, keyed separately from the app. Edge function secrets cannot start with SUPABASE_, which the comment in validateAuth records, so the external key gets its own name. earnings/utils.ts:60-64EPS and revenue: Earnings_Metrics
Actuals come from Massive income statements, refreshed by stocks/refresh-earnings: 15 tickers in flight per batch, 100 ms between batches, a 350 s wall-clock budget under the 400 s limit, and a 5-day staleness cutoff so the universe rotates. Consensus estimates come from Finnhub every six hours. status is computed as beat, miss, met or upcoming, or left null when there is nothing to compare. The Reddit-facing details endpoint returns the current quarter plus three prior. stocks/handlers/earningsRefresh.ts · migrations/20251216220000_create_earnings_metrics.sql · stocks/docs/EARNINGS_METRICS.md
When dates or numbers look wrong in prod, docs/EARNINGS_DATA_CLEANUP.md is the runbook: audit SQL for implausible event_dates, a refresh_earnings_metrics() call for the EPS backfill, and a table to record gaps that turn out to be upstream Quartr data.
Check yourself
Do this in the repo
- Read
src/supabase/functions/quartr-webhook-handling/index.tstop to bottom and write down the order of the six gates. Compare with lesson 1's order for the stocks router; note which gate exists here and not there. - With functions served, send an unsigned POST and confirm the status:
curl -i -X POST 'http://localhost:54321/functions/v1/quartr-webhook-handling' \ -H 'Content-Type: application/json' -d '{"type":"company.created"}' - Run the legacy graceful-failure test that exercises this path and see what it asserts:
cd test && node test-quartr-webhook-graceful-failure.js. Then decide which of its assertions belong in a Deno test undersrc/supabase/tests/functions/per CLAUDE.md.
Primary source
Quartr: Webhook security is what signature-verification.ts implements; read it once and the header names and the whsec_ handling stop looking arbitrary. For the general pattern, Svix: Verifying webhooks manually describes the same id/timestamp/signature scheme.
Next lesson: 0011 Brokerage and SnapTrade, the other webhook domain, where the payloads are a user's own money. Revisit 0003 if the three key types in the read-surface table felt blurry.
Ask your teacher: "show me one processor end to end" or "what would a Redis-backed replay set look like here?"