Designing API Webhook Subscription Filters: Server-Side Event Selection That Reduces Customer Workload
Most webhook APIs let customers subscribe by event type and then ship every event matching the type. For high-volume integrations this is wasteful on both sides. Server-side filtering lets customers narrow further without writing infrastructure to discard most of what they receive.
The minimum-viable webhook API lets customers subscribe by event type. For each subscription, the platform delivers every event whose type matches. The customer's receiver gets a flood of events, most of which are immediately discarded by application code that filters on object IDs, status values, or other payload fields. This works for low-volume integrations and breaks down at higher volumes where the bandwidth and processing cost on the receiver side starts to matter.
What server-side filtering actually does
Server-side filtering pushes the discard work upstream. Instead of the customer subscribing to invoice.updated and discarding 95% of events because they only care about invoices for a specific tenant, the customer subscribes to invoice.updated WHERE tenant_id = 'X' and the platform only delivers the 5% that match. The bandwidth saving on both sides can be substantial; the customer infrastructure simplifies because the receiver no longer needs filtering logic; and the audit trail is cleaner because the platform's delivery log shows only events the customer asked for.
The cost is implementation complexity on the platform side. The platform now has a per-subscription filter expression that needs to be evaluated for every candidate event, and the evaluation needs to be fast enough not to bottleneck the delivery pipeline. The expression language needs to be expressive enough to cover real customer needs and constrained enough to evaluate cheaply and stay debuggable.
The expression-language question
The choice of expression language is the load-bearing design decision. Three patterns dominate in real APIs.
Field-equals-value plus comparison. The simplest expression language allows equality and ordered comparison on top-level fields: amount > 100 AND status = 'paid' AND currency = 'USD'. This covers 70-80 percent of real filtering use cases (filter to tenant, filter to status, filter to amount range) with simple parser, fast evaluation, and minimal abuse vector. Implementations include Stripe's webhook filter rules and Linear's webhook filtering.
Path-aware predicate language. For events with nested payloads, a path-aware language lets customers reach into the structure: data.user.country = 'US' AND data.amount > 100. This is needed when the events are not flat (most non-trivial APIs), but the parser is more complex and the evaluator needs to handle missing-field cases. GitHub's webhook filtering and AWS EventBridge use variants of this pattern.
Full expression language. Some APIs expose JsonLogic or CEL or a custom DSL with arbitrary boolean expressions, function calls, and pattern matching. This is the most flexible but also the most operationally complex and the most prone to per-customer expressions that take too long to evaluate. Most production APIs converge on a constrained subset of the expression language rather than full power.
For B2B SaaS, field-equals-value plus comparison is the right default. The narrow language covers most customer needs, the operational cost is low, and the expression evaluator can be cached aggressively. The path-aware extension is worth adding when payload structure becomes deeply nested. The full expression language is rarely worth the operational cost outside specialized infrastructure platforms.
The schema
The minimum viable schema for filtered subscriptions adds a small number of columns to the subscription table. The base columns are id, customer_id, url, secret, and the existing event_types array. The filter columns are filter_expression (the customer-supplied expression as text), filter_compiled (the parsed AST or compiled form as JSON for fast evaluation), and filter_version (an integer that the platform increments when the expression language semantics change so old expressions can be migrated).
The compiled form is worth storing separately because parsing the expression on every candidate event would bottleneck the delivery pipeline. The parse-once-evaluate-many pattern matches how query planners work: the expression is parsed and validated on subscription creation, and the compiled form is loaded into worker memory at delivery time.
The validation on subscription creation should be strict — invalid expressions should return a 400 with a clear error message pointing at the column or operator that failed, not be silently accepted and produce delivery failures later. The validation should also check that the field references match the schema of the event types the subscription covers; a filter on invoice.id for a subscription that includes customer.created events should be rejected because the field does not exist for customer events.
The evaluation pipeline
The delivery pipeline already has a fan-out step that matches each event to subscriptions whose event_types array includes the event type. Adding filter evaluation extends this step: for each matching subscription, evaluate the compiled filter against the event payload, and only proceed with delivery if the filter returns true.
The evaluation cost should be a few microseconds per filter for the constrained expression language and target sub-millisecond for the path-aware language. For high-volume event streams (thousands of events per second), the filter evaluation dominates the fan-out step and should be designed to be cache-friendly. The simple optimization is to short-circuit on the cheapest condition first, so filters like tenant_id = 'X' AND payload_size > 10000 evaluate the tenant_id check first and skip the payload_size check on mismatch.
The error handling for filter evaluation matters because customer expressions can reference fields that do not exist on every event (the customer wrote a filter expecting one schema, but the event has a different shape). The right default is to treat missing fields as null and to evaluate the expression in null-handling mode (similar to SQL three-valued logic), with the option for the customer to explicitly require field existence via EXISTS or similar.
The customer-facing dashboard surface
The dashboard surface for filtered subscriptions is where most of the operational improvement happens. The customer creating a subscription needs to test their filter expression against historical events, and the platform needs to make that easy. The pattern is a filter-tester widget that takes a filter expression and shows which recent events (from the customer's own event history) would have matched or been rejected.
The test-against-recent-events feature is the difference between "customers write filter expressions confidently" and "customers write filter expressions, get unexpected gaps, and open support tickets." The implementation is straightforward — for the last N events the customer received in test mode or last 24 hours, evaluate the proposed filter against each and show the match rate plus a few example matched and rejected events.
The subscription list view should show the filter expression alongside the URL and event types so customers can see at a glance which subscriptions are filtered. The delivery log should include a filter-evaluation field for filtered subscriptions so customers can see, for each event that was not delivered, that the reason was filter mismatch rather than delivery failure.
What filters should not do
Three things filters should not do, even if customers ask.
Aggregate across events. A filter that says "only deliver events where the customer's cumulative spend this month exceeds 10000" requires the platform to maintain per-customer aggregate state and evaluate it on every event. The implementation complexity is high, the correctness questions around concurrent updates are hard, and the customer can achieve the same outcome with periodic API polling. Aggregate filters belong in customer-side processing, not in the webhook delivery path.
Transform the payload. A filter that says "only deliver the customer_id and amount fields, not the full payload" turns the filter from a selection mechanism into a projection mechanism. The customer benefit is small (the payload is small), the implementation complexity is large (versioned schemas, field-name changes, removed fields), and the abstraction creates customer dependencies on field-by-field stability rather than on payload structure. Projection belongs in the data export layer, not in the webhook delivery path.
Reorder or batch events. A filter that says "deliver matching events in batches of 10 every minute" turns the filter into a buffering mechanism. The delivery semantics change, the failure modes multiply (what happens when a batch partially fails), and the use case is better served by polling the event log API. Batching belongs in the asynchronous export layer.
Our use across the four products
Our products implement filtering at varying levels of sophistication. WebhookVault offers the most-developed filtering because the product is webhook-focused and customers commonly want to subscribe to events matching specific source endpoints, HTTP methods, or response status codes. The filtering uses the field-equals-value plus comparison language and runs against the captured request structure.
CronPing offers filtering on monitor status changes (filter to specific monitor IDs or status transitions). The filtering is narrow because the event schema is narrow.
FlagBit offers no explicit filtering because the event schema is narrow enough that subscribing by event type is sufficient for most cases; customers who need narrower selection can subscribe to a specific flag's update events via the resource-scoped subscription endpoint.
DocuMint emits events on invoice generation completion; the structure is flat enough that filtering by template, by customer email domain, or by amount range covers the common cases, and the language is the same constrained subset used elsewhere.
The deeper observation here is that webhook filtering is one of the features that customer demand creates over time. Most APIs ship without it and add it under pressure from high-volume customers; the platforms that ship it from the start avoid the integration churn that comes when customers have to refactor receivers to remove client-side filtering after server-side filtering becomes available. The narrow expression language is the right default because it covers most real customer needs and avoids the operational complexity that arbitrary expressions create.
Our products: DocuMint (PDF invoice generation API), CronPing (cron job monitoring with status pages), FlagBit (feature flags API for modern teams), and WebhookVault (webhook capture and replay) put these patterns into production.