Back to Blog
webhooksroutingfilteringarchitecturereliability

Webhook Event Filtering and Conditional Routing: Reducing Noise at the Gateway

Sending every event to every destination is the easiest implementation and the worst for reliability. Here's how to design event filtering and conditional routing so consumers only receive the events they actually need.

M
Marcus Webb
Platform Engineer
April 6, 2026
9 min read

Most webhook platforms start with a simple routing model: one source, one destination, everything delivered. It works for a first integration. It doesn't work when you have 15 destinations, each with different requirements, and a source emitting 40 distinct event types.

Without filtering, every destination receives every event. Consumers that only care about payment.completed are wading through payment.disputed, payment.refund.created, subscription.trial.ending, and 36 other types they'll silently ignore — or worse, handle incorrectly because they weren't designed for those payloads. The noise creates load, inflates retry queues when irrelevant events fail, and makes debugging harder because the delivery log is cluttered with irrelevant traffic.

Event filtering is the mechanism that fixes this. Done right, it reduces delivery volume, improves consumer reliability, and gives operators a clear map of what goes where and why.


The Two Layers of Routing

Before designing a filter system, it helps to be clear about what "routing" means at different layers:

LayerQuestion answeredImplemented where
Source routingWhich destinations receive events from this source?Route table: source → destination
Event type filteringWhich event types does each destination receive?Filter expression on the route
Payload filteringWhich events matching the type meet additional criteria?Deep filter on payload fields

Most platforms implement only the first layer — any event from source S goes to destination D. The second layer (event type filtering) is the highest-value addition and the lowest implementation cost. The third layer (payload filtering) is powerful but requires a filter expression language, which introduces complexity you should only take on if your customers demonstrably need it.

This post focuses on layers two and three, assuming layer one is already in place.


Event Type Filtering: Pattern Matching on the Type Field

Every webhook event should carry a structured type string — a hierarchical identifier like payment.completed, user.created, or subscription.cancelled. This convention is well-established (Stripe, GitHub, Shopify, and most major platforms use it), and it's the primary hook for filtering.

The simplest filter is an exact match: "deliver this event to destination D only if the type is payment.completed." The more useful version is glob-style pattern matching: "deliver to D if the type matches payment.*."

Pattern matching lets consumers subscribe to logical groups without enumerating every type:

payment.*           — all payment events
subscription.*      — all subscription lifecycle events
user.created        — only user creation, not updates or deletes
*.failed            — any event type ending in .failed
*                   — everything (the default, no filter)

The implementation in Go is straightforward with path.Match from the standard library:

go
import "path"

// MatchesEventType returns true if the event type matches the route's filter pattern.
// An empty pattern is treated as a wildcard and matches everything.
func MatchesEventType(eventType, pattern string) bool {
    if pattern == "" || pattern == "*" {
        return true
    }
    matched, err := path.Match(pattern, eventType)
    if err != nil {
        // path.Match only returns an error for malformed patterns.
        // Patterns should be validated at route creation time, not here.
        return false
    }
    return matched
}

Validate patterns at route creation time — reject malformed patterns before they're stored, so the delivery path never encounters them:

go
func ValidateEventTypePattern(pattern string) error {
    if pattern == "" {
        return nil // empty = wildcard, always valid
    }
    // path.Match with a dummy string will return ErrBadPattern if the pattern is malformed
    _, err := path.Match(pattern, "test.event")
    if err != nil {
        return fmt.Errorf("invalid event type pattern %q: %w", pattern, err)
    }
    return nil
}

Store the pattern on the route record:

sql
ALTER TABLE routes ADD COLUMN event_type_pattern TEXT NOT NULL DEFAULT '';

-- Example routes
INSERT INTO routes (source_id, destination_id, event_type_pattern)
VALUES
  ('src_payments', 'dst_billing_service',   'payment.*'),
  ('src_payments', 'dst_fraud_detector',    'payment.failed'),
  ('src_payments', 'dst_audit_log',         '*'),
  ('src_users',    'dst_crm_sync',          'user.*');

At delivery time, the worker queries routes for the source and evaluates each pattern against the event type before enqueuing:

go
func (w *Worker) routeEvent(ctx context.Context, event Event) error {
    routes, err := w.store.GetRoutesForSource(ctx, event.SourceID)
    if err != nil {
        return fmt.Errorf("fetching routes: %w", err)
    }

    for _, route := range routes {
        if !MatchesEventType(event.Type, route.EventTypePattern) {
            continue // filtered out — do not enqueue for this destination
        }
        if err := w.store.EnqueueDelivery(ctx, event.ID, route.DestinationID); err != nil {
            return fmt.Errorf("enqueuing delivery to %s: %w", route.DestinationID, err)
        }
    }
    return nil
}

This is the critical decision point: filtering happens before enqueuing, not after. An event that doesn't match a route's pattern is never enqueued for that destination — it doesn't consume queue capacity, doesn't trigger delivery attempts, and doesn't appear in that destination's delivery log. The filter is invisible from the destination's perspective, which is exactly right.


Payload Filtering: Field-Level Conditions

Event type filtering handles the common case. Some consumers need finer-grained control: "deliver order.completed events only when data.amount_cents is greater than 10,000" or "deliver user.updated events only when data.plan changed to enterprise."

This is payload filtering, and it requires a lightweight expression language. There are several approaches, roughly ordered by implementation complexity:

ApproachExpressivenessImplementation effortRisk
Simple key=value equalityLowMinimalLow
JSONPath + comparison operatorsMediumModerateLow-medium
CEL (Common Expression Language)HighHighMedium
JMESPathMediumLow (library exists)Low
Custom DSLVariableHighHigh (maintenance burden)

For most webhook platforms, JSONPath with comparison operators is the right tradeoff. It's expressive enough for real use cases, has multiple Go libraries (github.com/PaesslerAG/jsonpath, github.com/ohler55/ojg), and produces filter expressions that are human-readable.

A filter expression looks like:

json
{
  "conditions": [
    { "path": "$.data.amount_cents", "op": "gt", "value": 10000 },
    { "path": "$.data.currency",     "op": "eq", "value": "USD"  }
  ],
  "mode": "all"
}

mode: "all" requires every condition to match (AND). mode: "any" requires at least one (OR).

Store the filter as a JSONB column on the route:

sql
ALTER TABLE routes ADD COLUMN payload_filter JSONB;

Evaluate at delivery time, after the event type pattern passes:

go
func EvaluatePayloadFilter(payload []byte, filter PayloadFilter) (bool, error) {
    if filter.IsEmpty() {
        return true, nil // no filter = match everything
    }

    var doc interface{}
    if err := json.Unmarshal(payload, &doc); err != nil {
        return false, fmt.Errorf("parsing payload for filter evaluation: %w", err)
    }

    results := make([]bool, 0, len(filter.Conditions))
    for _, cond := range filter.Conditions {
        val, err := jsonpath.Get(cond.Path, doc)
        if err != nil {
            // Path not found — treat as non-match, not an error
            results = append(results, false)
            continue
        }
        match, err := evaluateCondition(val, cond.Op, cond.Value)
        if err != nil {
            return false, err
        }
        results = append(results, match)
    }

    if filter.Mode == "any" {
        for _, r := range results {
            if r {
                return true, nil
            }
        }
        return false, nil
    }

    // Default: "all"
    for _, r := range results {
        if !r {
            return false, nil
        }
    }
    return true, nil
}

One important operational note: if the payload filter errors (malformed path, type mismatch on comparison), do not silently drop the event. Log the error and fall back to delivering the event, or route it to a dead-letter queue with a filter evaluation failure tag. Silent drops are worse than noisy failures.


Surfacing Filters to Operators

Filters are only as useful as their observability. Operators need to be able to answer:

  • "Why didn't this event reach destination D?"
  • "How many events were filtered out by this route this week?"
  • "Which routes have no filter (receiving everything)?"

The delivery log should record filter outcomes. When an event is filtered out by a route, write a record with outcome: filtered rather than writing nothing. This gives operators visibility into why an event didn't reach a destination without the confusion of a missing record.

sql
CREATE TABLE routing_decisions (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id    UUID NOT NULL REFERENCES events(id),
    route_id    UUID NOT NULL REFERENCES routes(id),
    outcome     TEXT NOT NULL CHECK (outcome IN ('enqueued', 'filtered_type', 'filtered_payload')),
    evaluated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

This table is append-only and can be retained for 7–30 days depending on your data retention policy. It answers "why didn't this event reach destination D" in a single query:

sql
SELECT r.name AS route_name, rd.outcome, rd.evaluated_at
FROM routing_decisions rd
JOIN routes r ON r.id = rd.route_id
WHERE rd.event_id = 'evt_01HX9P...'
ORDER BY rd.evaluated_at;

GetHook surfaces this decision log in the event detail view — each event shows which routes were evaluated, which matched, and which filtered, so debugging a missing delivery takes seconds rather than a support ticket.


Operational Patterns

A few patterns that save teams time when operating a filter-based routing system:

Test filters before saving them. Provide an API endpoint that evaluates a filter expression against a sample payload and returns the match result. This catches misconfigured filters before they silently drop production events.

Audit routes with no event type filter. A route with event_type_pattern = '*' or empty is a wildcard — every event from the source reaches that destination. This is sometimes intentional (audit log destinations) but often accidental. Surface these in your dashboard as a warning.

Cap filter expression complexity. If you implement payload filtering with JSONPath or CEL, set a maximum expression size and a maximum evaluation time (50ms is generous). A pathological filter expression shouldn't be able to slow down your delivery worker.

Version filters alongside event schemas. When you add new event types, check whether existing filters will match them unintentionally. A route with pattern user.* will start delivering user.deleted if you add that event type, whether the destination is ready for it or not. Document this behavior clearly for operators and consider requiring explicit opt-in for new event types in production routes.


Filtering is unsexy infrastructure that pays for itself immediately when your platform grows beyond a handful of integrations. The event type pattern check takes microseconds and eliminates entire categories of unnecessary delivery attempts. Payload filtering adds power for consumers with more specific needs without complicating the common case.

If you want routing with event type pattern matching and per-route filtering built in, start with GetHook — routes support glob-style event type patterns out of the box, so you can configure conditional delivery from day one.

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.