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.
| Transformation | What It Does | Primary Motivation |
|---|---|---|
| Normalize | Map provider-specific fields to a canonical schema | Decouple consumers from provider formats |
| Enrich | Inject additional context (tenant ID, environment, metadata) | Give consumers information they'd otherwise have to fetch |
| Redact | Remove or mask sensitive fields before forwarding | PII 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:
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:
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 tag —
production,staging, orsandbox, 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:
{
"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:
// 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:
{
"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 case | Right layer |
|---|---|
Map data.object.id → subscription_id for all subscribers | Gateway |
| Convert Unix timestamp to RFC3339 for all subscribers | Gateway |
| Inject tenant context from the ingest token | Gateway |
| Redact PII before third-party log forwarding | Gateway |
| Compute a derived metric from two payload fields | Consumer |
Skip events where status != "active" for a specific workflow | Consumer (or route filter) |
| Aggregate data across two separate event types | Consumer |
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.
# 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.jsonThen 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.