A webhook relay receives an incoming webhook, transforms the payload, and fans it out to multiple destinations.
A webhook relay receives an incoming webhook, transforms the payload, and fans it out to multiple destinations. This decouples the source system from the downstream consumers -- the source only needs to send one webhook, and your relay handles distribution, transformation, and per-destination formatting.
Real-world example: Stripe sends payment events to a single webhook endpoint. Your relay routes the event to your application backend, your analytics pipeline, and your accounting system, each receiving the data in its own format.
Webhook Trigger (/webhook/stripe-relay)
|
Code Node: Validate Stripe Signature
|
Switch Node: Route by Event Type
|
+-> "charge.succeeded":
| +-> HTTP Request: App Backend (full payload)
| +-> HTTP Request: Analytics (minimal payload)
| +-> HTTP Request: Accounting System (financial fields only)
|
+-> "customer.subscription.updated":
| +-> HTTP Request: App Backend
| +-> HTTP Request: CRM Update
|
+-> Default:
+-> Log to Google Sheets (unhandled event types)
```text
```javascript title="Code Node: Validate Stripe Signature"
const crypto = require('crypto');
// Use rawBody to get the exact bytes sent by Stripe.
// Never use JSON.stringify() -- it may reorder keys or alter whitespace.
const payload = $json.rawBody;
const signature = $json.headers['stripe-signature'];
const webhookSecret = $env.STRIPE_WEBHOOK_SECRET;
// Parse the Stripe signature header
const elements = signature.split(',');
const timestamp = elements
.find(e => e.startsWith('t='))?.split('=')[1];
const expectedSig = elements
.find(e => e.startsWith('v1='))?.split('=')[1];
// Compute the expected signature
const signedPayload = `${timestamp}.${payload}`;
const computedSig = crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex');
// Use constant-time comparison to prevent timing attacks
const computedBuffer = Buffer.from(computedSig, 'utf8');
const expectedBuffer = Buffer.from(expectedSig || '', 'utf8');
if (computedBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(computedBuffer, expectedBuffer)) {
throw new Error('Invalid Stripe signature');
}
// Signature valid -- pass through the event
return [{ json: $json.body }];
```text
> **Warning: Enable Raw Body on the Webhook node**
>
> This code requires the Webhook node's **Raw Body** option to be enabled. Without it, `rawBody` will be undefined and signature verification will fail silently.
```json title="HTTP Request: Analytics (minimal payload)"
{
"method": "POST",
"url": "https://analytics.example.com/api/events",
"sendBody": true,
"bodyContentType": "json",
"jsonBody": {
"event": "={{ $json.type }}",
"amount": "={{ $json.data.object.amount }}",
"currency": "={{ $json.data.object.currency }}",
"timestamp": "={{ $json.created }}"
}
}
```text
> **Tip: Respond Immediately**
>
> Configure the Webhook node with **Response Mode: Immediately**. Stripe (and most webhook senders) require a 200 response within a few seconds. Your relay should acknowledge receipt immediately, then process asynchronously. If per-destination delivery fails, that is handled by retry logic on each downstream branch, not by making Stripe wait.
This pattern centralizes webhook management, eliminates duplicate webhook configurations in the source system, and lets each destination receive exactly the data format it needs.
**Related:** [Use Path Parameters in Webhook URLs for Dynamic Routing](../webhook-mastery/01-use-path-parameters-in-webhook-urls-for-dynamic-routing.md) | [Use Edit Fields in "Map Each" Mode for Simple Renames](../data-transformation/01-use-edit-fields-in-map-each-mode-for-simple-renames.md)
I build production n8n and Cloudflare automation for teams — the same engineering behind HarperFlow. Fixed-price, escrow-protected, US-based.