Back to Blog
webhooksarchitecturegatewaydata engineeringsecurity

Webhook Payload Transformation: Normalizing, Enriching, and Redacting Events at the Gateway

Every provider speaks a different payload dialect. Instead of writing adapter code in every consumer, push normalization, enrichment, and sensitive-data redaction to the gateway layer — where it runs once and benefits everything downstream.

P
Priya Nair
Developer Advocate
April 27, 2026
9 min read

Stripe wraps events in a data.object envelope. GitHub puts the action type in a top-level action field. Shopify uses snake_case resource fields but inconsistent timestamp formats. PagerDuty nests everything under messages[].event.data. Each provider speaks a slightly different dialect of JSON, and every consumer you write has to understand all of them.

The typical response is per-consumer adapter code: a Stripe normalizer here, a GitHub mapper there, duplicated across every service that subscribes to inbound events. This approach accrues technical debt faster than it accrues functionality. Schema changes at the provider break multiple consumers simultaneously, and there's no single place to audit what transformation logic exists.

The better architecture moves this work upstream — to the gateway layer, before events reach any consumer. This post explains how to think about gateway-layer transformation, covers the three main transformation types, and walks through the implementation decisions that matter.


The Three Transformation Types

Webhook payload transformation isn't one thing. It's three distinct operations with different purposes, different risks, and different ordering requirements.

TransformationWhat It DoesPrimary Motivation
NormalizeMap provider-specific fields to a canonical schemaDecouple consumers from provider formats
EnrichInject additional context (tenant ID, environment, metadata)Give consumers information they'd otherwise have to fetch
RedactRemove or mask sensitive fields before forwardingPII compliance, third-party log safety

These three operations should run in this order: normalize first, then enrich, then redact. The reason is compositional: enrichment rules can reference canonical field names (post-normalize), and redaction can sweep both original and enriched fields (post-enrich). Reversing the order means writing rules against unstable field names or risking that enriched sensitive data escapes the redaction pass.


Normalization: One Schema to Rule Them All

Normalization maps the provider's native payload shape to a schema your organization defines and controls. The canonical schema is yours — designed around what your consumers actually need, not what each provider happened to ship.

A minimal normalization rule associates a source field path (in dot notation) with a target field in the output:

go
type NormRule struct {
	SourcePath string          // dot-notation path in the provider payload
	TargetKey  string          // field name in the normalized output
	Transform  func(any) any   // optional value conversion (nil = identity)
}

func applyNormRules(payload map[string]any, rules []NormRule) map[string]any {
	out := make(map[string]any, len(rules))
	for _, r := range rules {
		val := deepGet(payload, r.SourcePath)
		if r.Transform != nil {
			val = r.Transform(val)
		}
		out[r.TargetKey] = val
	}
	return out
}

// deepGet resolves "data.object.customer" into nested map lookups.
func deepGet(m map[string]any, path string) any {
	parts := strings.SplitN(path, ".", 2)
	v, ok := m[parts[0]]
	if !ok || len(parts) == 1 {
		return v
	}
	if nested, ok := v.(map[string]any); ok {
		return deepGet(nested, parts[1])
	}
	return nil
}

A rule set for a Stripe customer.subscription.updated event might look like:

go
stripeSubscriptionRules := []NormRule{
	{SourcePath: "data.object.id",          TargetKey: "subscription_id"},
	{SourcePath: "data.object.customer",    TargetKey: "customer_id"},
	{SourcePath: "data.object.status",      TargetKey: "status"},
	{SourcePath: "data.object.current_period_end", TargetKey: "period_end",
		Transform: func(v any) any {
			// Stripe sends Unix timestamps; convert to RFC3339
			if ts, ok := v.(float64); ok {
				return time.Unix(int64(ts), 0).UTC().Format(time.RFC3339)
			}
			return v
		},
	},
}

The key discipline here is keeping rule sets versioned alongside the provider's schema. When Stripe adds a field or renames one, your rules update — not eight consumer services.


Enrichment: Context Your Consumers Shouldn't Have to Fetch

Enrichment injects data the consumer would otherwise have to retrieve from an internal service. The gateway is the right place to do this because it sees every event from a given source and can amortize the cost of context lookups.

Common enrichment targets:

  • Tenant/account ID — derived from the source token or API key used on the ingest endpoint
  • Environment tagproduction, staging, or sandbox, based on which ingest URL received the event
  • Received timestamp — set at ingest, not trusted from the provider payload
  • Event type canonical name — your normalized event type string, not the provider's

A straightforward enrichment envelope looks like this:

json
{
  "event_id": "evt_01HZ3KQPMR7NXB8YV4W9Q2T5S6",
  "event_type": "subscription.updated",
  "source": "stripe",
  "received_at": "2026-04-27T14:32:01Z",
  "tenant_id": "acct_f3a9c2d1",
  "environment": "production",
  "data": {
    "subscription_id": "sub_1OxKq2LkdIwHu7ix5l4ABCD",
    "customer_id": "cus_PxRt7mNqLw3456",
    "status": "active",
    "period_end": "2026-05-27T00:00:00Z"
  }
}

The consumer receives a complete, self-describing event. It never has to make an internal API call to learn which tenant this event belongs to or when it arrived.

One thing to be careful about: enrichment that requires a database lookup adds latency to the forwarding path. Keep enrichment lookups fast — in-memory cache with a short TTL, or a lightweight read replica query. If enrichment regularly takes more than a few milliseconds, it's worth questioning whether it belongs in the hot path at all.


Redaction: Stripping Sensitive Data Before It Spreads

Redaction removes or masks fields that shouldn't reach certain destinations. This matters in two scenarios: forwarding events to third-party destinations (logging aggregators, analytics services) that shouldn't see raw PII, and long-term event storage subject to data minimization requirements.

A field-path-based redaction pass is straightforward to implement:

go
// redactFields removes dot-notation paths from a nested map, in place.
func redactFields(payload map[string]any, paths []string) {
	for _, path := range paths {
		parts := strings.SplitN(path, ".", 2)
		if len(parts) == 1 {
			delete(payload, parts[0])
			continue
		}
		if nested, ok := payload[parts[0]].(map[string]any); ok {
			redactFields(nested, []string{parts[1]})
		}
	}
}

For a destination that receives subscription events but must not receive cardholder data, a redaction config might specify:

json
{
  "redact_paths": [
    "data.card_last4",
    "data.billing_email",
    "data.shipping_address"
  ]
}

You can also replace values rather than deleting them — replacing "billing_email" with "[REDACTED]" preserves the schema shape while removing the content, which can matter if downstream consumers do schema validation.

Critically: run your redaction pass on a deep copy of the payload, not the original. The gateway may need to store the unredacted event for replay purposes, even while forwarding only the redacted version.


When to Keep Transformation Out of the Gateway

The gateway is the right place for transformation that applies to all consumers of a given source. It's the wrong place for transformation that encodes business logic specific to one consumer.

Use caseRight layer
Map data.object.idsubscription_id for all subscribersGateway
Convert Unix timestamp to RFC3339 for all subscribersGateway
Inject tenant context from the ingest tokenGateway
Redact PII before third-party log forwardingGateway
Compute a derived metric from two payload fieldsConsumer
Skip events where status != "active" for a specific workflowConsumer (or route filter)
Aggregate data across two separate event typesConsumer

The test is: does this transformation benefit every downstream consumer equally, or does it encode something specific to one? If it's the former, it belongs in the gateway. If it's the latter, push it to the consumer.


Testing Transformation Rules

Transformation rules are code. They need tests. The most useful form is a snapshot test: given a real provider payload (captured from staging or the provider's docs), assert the exact normalized output.

bash
# Capture a real Stripe event payload for use in tests
curl https://api.stripe.com/v1/events/evt_1OxKq2... \
  -u sk_test_... \
  -o testdata/stripe_subscription_updated.json

Then write a table-driven test that runs each rule set against its corresponding fixture and compares output to a golden file. When a provider updates their schema and your rules need updating, the failing test tells you exactly which output fields changed.

Also test the pipeline order explicitly: confirm that a redaction rule targeting a field injected during enrichment actually removes it. Pipeline ordering bugs are subtle and rarely caught without a test that exercises the full chain.


Keeping Rules in Sync with Provider Schemas

The biggest operational risk of gateway-layer transformation is rule lag: the provider changes a field name, your rules silently map the wrong field or return null, and consumers degrade without an obvious error.

Mitigations:

  • Alert on null enrichment — if a field your rules expect to populate comes out nil, emit a metric and alert. Don't silently forward a broken event.
  • Version your rule sets — store rules as named, versioned configs. When a provider announces a schema change, update the rule set and cut a new version before the provider's cutover date.
  • Monitor the canonical schema — treat your normalized schema as a contract. Consumers should document which fields they depend on. Schema drift at the provider is detectable before it becomes a consumer-breaking failure.

GetHook surfaces per-route transformation configuration so you can define normalization and redaction rules alongside your routing config — without deploying new consumer code when a provider's schema shifts.


The Payoff

The upfront cost of defining a canonical schema and writing transformation rules is real. So is the payoff: every consumer you add from that point forward receives clean, consistent, context-rich events. Provider schema changes become a gateway configuration update rather than a multi-service incident. PII redaction is a policy decision made once, not a code change in every consumer that touches sensitive events.

If you're building or extending webhook infrastructure and want to see how gateway-layer transformation fits into a complete delivery pipeline, start here.

Stop losing webhook events.

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