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

1Quartr POSTs to /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.ts
2Rate limit by client IP, read from x-forwarded-for, x-real-ip or cf-connecting-ip. security/rate-limiter.ts
3Verify the HMAC-SHA256 signature over the raw body string, using the whsec_-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.ts
4Reject replays by webhook-id. The set is in-memory per isolate, capped and halved when full. security/replay-protection.ts
5Route by event type to one of four processors: company, event, live audio, live transcript, each with created / updated / deleted. Processing is in-memory writes through a service-role client; no outbound HTTP. webhook-router.ts:44-89 · processors/
6Log the outcome with a correlation id and update in-process metrics. database/logging.ts, utils/metrics.ts
Why the raw body, not the parsed JSON. The signature was computed by Quartr over the exact bytes they sent. Re-serialising a parsed object can reorder keys or change whitespace and the HMAC will not match. Read await 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

TableWritten byPurpose
Companiescompany processorthe company universe; other domains join to quartr_company_id
Eventsevent processorscheduled calls with event_date, fiscal year and period
Earnings_Calls_Live_Audiolive audio processorstream state and URL while a call is live
Earnings_Calls_Live_Transcriptslive transcript processorrunning transcript while a call is live
Historical_Events, Historical_Audio, Historical_Transcriptsprivate.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

FunctionAuthRoutingReads
earningsPUBLISHABLE_KEY via apikey or bearer; the secret key is explicitly rejectedURLPattern('/earnings/:resourceType') against an enum of six resource typeslive and historic audio/transcript, schedule, calendar
earnings-cacheuser JWT, forwarded to the client so RLS appliessingle routeget_earnings_cache_for_user() RPC; per-user Upstash Redis rate limit of one call per 30 s
stocks/detailspublicsee lesson 6Earnings_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

Why the env var is 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-64

EPS 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

  1. Read src/supabase/functions/quartr-webhook-handling/index.ts top 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.
  2. 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"}'
  3. 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 under src/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.