One inbox for every filing alert — web, mobile, email, and webhooks
Last year I wired a cron job that polled EDGAR and posted new 8-Ks into a Slack channel. It worked for about a week. Then came the rate limits, the duplicate posts after a restart, and a teammate asking why the same filing showed up in email and Slack with different timestamps. We fixed each bug in isolation. The underlying problem — four transports with four different ideas of what "new" means — stayed.
When we built alerts properly for OpenFilings, we made one decision early: one record per event per user, then let each client decide how to surface it.
The duct-tape phase
The temptation with notifications is to ship the channel that matters most right now and move on. Email digest for the first users. Live updates when you add a web workbench. Push when you ship mobile. Webhooks when a B2B customer asks.
The problem is not any single channel — it is that each one ends up with its own dedup logic, its own "read" flag, its own delivery path. By the time you have three of them, you are not maintaining a notification system; you are maintaining three systems that happen to mention the same ticker. The duct tape compounds.
We had already lived through it once with the Slack cron job. We did not want to rebuild it at product scale.
One backend, one inbox
Every notification lands in a single inbox record, tied to your account. Web, mobile, email, and outbound webhooks all read from that same underlying record — there is no per-channel copy that can drift out of sync with the others.
filing discovered → fan-out → your inbox
├─ live updates (workbench)
├─ REST inbox + badge count
├─ email
├─ mobile push
└─ your webhooks (if configured)
The fan-out does not branch by channel at write time. We record the event once, then each delivery path checks your notification preferences and skips itself if you turned that channel off. Adding a new channel means adding a new consumer of that one record — not touching how the event was generated in the first place.
What triggers a notification
Two event types fan out today:
| Event | When |
|---|---|
filing.discovered | A new filing is seen for the first time — not on every re-check |
press_release.discovered | A new GlobeNewswire item is matched to a company (first insert only; amendments do not re-notify) |
insider_transaction.discovered from SEC Form 4 is next, same inbox, same semantics.
Who gets notified is resolved from your watchlist (by ticker or matched entity), plus anyone who has opted in to see all activity rather than just their own list — new users get that broader view by default on first login, so there's something to see before they've built a watchlist. Preferences can narrow this further by event type and by market.
Fan-out at scale
When a filing is discovered, the fan-out step:
- Loads the filing's ticker and matched entity
- Collects every account with a matching watchlist entry, or opted into all activity
- Applies each account's preference filters
- Writes the resulting notifications in one batch, with a uniqueness guarantee so a retry after a crash never double-delivers the same event to the same person
On days with heavy filing volume, step 2 — finding who's watching a given ticker — was the bottleneck against a full table scan. We keep a fast, purpose-built lookup index of watchlist subscribers per ticker, refreshed whenever someone adds or removes a name, so that lookup stays fast independent of how much history has built up.
Press releases are messier. Wire items often arrive with a security identifier before any exchange ticker is resolved. Fan-out matches on whichever identifier resolves to a known company first — matched entity, security ID, or display ticker.
Five delivery channels
| Channel | Mechanism | Typical use |
|---|---|---|
| REST inbox | GET /api/user/notifications | Mobile polling, scripts |
| Live updates | GET /api/user/notifications/stream | Workbench badge + toast |
| Transactional email | Off-hours catch-up | |
| Push | Mobile push notification | Background on phone |
| Webhooks | POST to your URL | Slack, PagerDuty, internal queue |
Push payloads are intentionally minimal — a pointer, not a document:
{
"notification_id": "uuid",
"event": "filing.discovered",
"filing_id": "uuid",
"ticker": "AAPL",
"form_type": "10-K"
}
The reasoning: push notifications get interrupted, batched, and delayed by the OS.
Embedding full filing metadata in a payload that might arrive 90 seconds late and
out-of-order seemed worse than a pointer you fetch on tap. Clients call
GET /api/user/notifications/{id} for detail, or jump straight to the filing page.
When a device stops accepting pushes (uninstalled app, expired token), we deactivate
that device rather than retrying into a dead end.
Cross-device read sync
Read state lives on the server, not in each app's local storage.
During last earnings season I had ~40 unread on the workbench for filings I had already
swiped through on my phone the night before. The fix was obvious once you name it:
client badge state is a cache, not the source of truth. PATCH /api/user/notifications/{id}/read
and POST .../read-all are the only write paths. On app resume, refresh
GET /api/user/notifications/unread-count and take the server's word for it.
Marking read on your phone clears the badge on the workbench. It sounds like table stakes — it is, and most filing products still do not do it.
Preferences and webhooks
GET/PATCH /api/user/notification-preferences controls four things: whether you see
just your watchlist or all activity, which channels are on, which event types you want,
and which markets to include.
User webhooks (/api/user/webhooks) are CRUD with signed delivery — point them at a
Slack incoming URL, an internal queue, or a Zapier bridge. Same payload contract as the
REST inbox, different transport. No separate enterprise tier or divergent schema.
What is still rough
Digest scheduling is not there yet — email is instant or nothing, no "send me a 9 AM summary." Press release push titles are sparse; we have the entity name and the headline, but not everything we extract at index time is fully surfaced in the notification body yet.
The inbox model is solid. What remains is mostly plumbing and polish on the transport layer — which, after the duct-tape phase, feels like the right problem to have.