HTTP Webhook Receiver
This example demonstrates receiving HTTP webhooks from external services, validating their signatures, and routing them to different destinations based on content. It's useful for integrating with services like GitHub, Stripe, Slack, or any system that sends webhook notifications.
Download & Run
Quick Start:
# Download and run directly
curl -sSL https://docs.expanso.io/examples/http-webhook.yaml | expanso-edge run -
# Or download first, customize, then run
curl -o my-pipeline.yaml https://docs.expanso.io/examples/http-webhook.yaml
expanso-edge run -f my-pipeline.yaml
Download: http-webhook.yaml
What This Pipeline Does
- Receives HTTP POST requests at multiple webhook endpoints
- Validates webhook signatures using HMAC-SHA256
- Parses JSON payloads and extracts event type
- Routes events to different outputs based on type
- Responds with appropriate HTTP status codes
Complete Pipeline
input:
http_server:
address: 0.0.0.0:8080
path: /webhooks/{source}
allowed_verbs:
- POST
timeout: 30s
sync_response:
status: "${! json(\"status\").or(\"200\") }"
headers:
Content-Type: application/json
pipeline:
processors:
# Extract webhook source from path parameter
- mapping: |
meta source = @http_server_request_path.trim_prefix("/webhooks/")
root = this
# Parse JSON payload
- mapping: |
root = this.parse_json()
root.webhook_source = @source
root.received_at = now()
# Validate signature (for sources that provide one)
- branch:
request_map: |
root.payload = content()
root.signature = @"X-Hub-Signature-256".or(@"X-Signature").or("")
root.secret = env("WEBHOOK_SECRET").or("")
processors:
- mapping: |
let expected = "sha256=" + this.payload.hash("hmac_sha256", this.secret).encode("hex")
root.valid = if this.signature == "" {
true # No signature provided, skip validation
} else {
this.signature == expected
}
result_map: |
root.signature_valid = this.valid
# Drop invalid signatures and return 401
- mapping: |
root = if this.signature_valid == false {
root.status = "401"
root.error = "Invalid signature"
this
} else {
root.status = "200"
this
}
# Log the webhook
- log:
level: INFO
message: "Received webhook"
fields_mapping: |
root.source = this.webhook_source
root.event = this.event.or(this.type).or("unknown")
output:
switch:
cases:
# Invalid signature - respond with error
- check: this.status == "401"
output:
sync_response: {}
# Route based on webhook source or event type
- check: this.webhook_source == "github"
output:
broker:
outputs:
- sync_response: {}
- http_client:
url: ${GITHUB_EVENTS_URL}
verb: POST
headers:
Content-Type: application/json
- check: this.webhook_source == "stripe"
output:
broker:
outputs:
- sync_response: {}
- http_client:
url: ${STRIPE_EVENTS_URL}
verb: POST
headers:
Content-Type: application/json
# Default: acknowledge and log
- output:
broker:
outputs:
- sync_response: {}
- stdout:
codec: lines
Configuration Breakdown
Input: HTTP Server with Path Parameters
input:
http_server:
address: 0.0.0.0:8080
path: /webhooks/{source}
allowed_verbs:
- POST
timeout: 30s
sync_response:
status: "${! json(\"status\").or(\"200\") }"
headers:
Content-Type: application/json
The http_server input listens on port 8080. The {source} path parameter captures the webhook source (e.g., /webhooks/github, /webhooks/stripe). The dynamic sync_response.status allows processors to control the HTTP response code.
See: HTTP Server Input
Processors: Validate and Route
1. Extract Source from Path
- mapping: |
meta source = @http_server_request_path.trim_prefix("/webhooks/")
root = this
Extracts the webhook source from the URL path and stores it as metadata for later use.
2. Parse Payload and Add Metadata
- mapping: |
root = this.parse_json()
root.webhook_source = @source
root.received_at = now()
Parses the JSON payload and enriches it with the source identifier and timestamp.
3. Validate Webhook Signature
- branch:
request_map: |
root.payload = content()
root.signature = @"X-Hub-Signature-256".or(@"X-Signature").or("")
root.secret = env("WEBHOOK_SECRET").or("")
processors:
- mapping: |
let expected = "sha256=" + this.payload.hash("hmac_sha256", this.secret).encode("hex")
root.valid = if this.signature == "" {
true # No signature provided, skip validation
} else {
this.signature == expected
}
result_map: |
root.signature_valid = this.valid
Uses a branch processor to validate HMAC-SHA256 signatures. This is compatible with GitHub's X-Hub-Signature-256 header and similar patterns used by other services.
See: Branch Processor
Output: Route by Source
output:
switch:
cases:
- check: this.webhook_source == "github"
output:
broker:
outputs:
- sync_response: {}
- http_client:
url: ${GITHUB_EVENTS_URL}
verb: POST
Routes webhooks to different destinations based on their source. The broker sends both a synchronous response back to the sender and forwards the event to downstream services.
See: Switch Output | Broker Output
Try It Locally
1. Start the Pipeline
# Set optional environment variables
export WEBHOOK_SECRET="your-secret-here"
export GITHUB_EVENTS_URL="http://localhost:9000/events"
export STRIPE_EVENTS_URL="http://localhost:9001/events"
# Run the pipeline
expanso-edge run -f http-webhook.yaml
2. Send Test Webhooks
Simple webhook (no signature):
curl -X POST http://localhost:8080/webhooks/test \
-H "Content-Type: application/json" \
-d '{"event": "test.event", "data": {"id": "123"}}'
GitHub-style webhook with signature:
# Generate signature
PAYLOAD='{"event": "push", "repository": "my-repo"}'
SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "your-secret-here" | cut -d' ' -f2)"
curl -X POST http://localhost:8080/webhooks/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIGNATURE" \
-d "$PAYLOAD"
3. Expected Responses
Valid webhook:
HTTP/1.1 200 OK
Content-Type: application/json
Invalid signature:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error": "Invalid signature"}
Common Variations
Filter by Event Type
Only process specific event types:
pipeline:
processors:
- mapping: |
root = this.parse_json()
# Only process specific events
- mapping: |
root = if ["push", "pull_request", "release"].contains(this.event.or(this.action)) {
this
} else {
deleted()
}
Add Rate Limiting
Protect against webhook floods:
rate_limit_resources:
- label: webhook_limit
local:
count: 100
interval: 1m
input:
http_server:
path: /webhooks/{source}
rate_limit: webhook_limit
Store Webhooks for Replay
Save webhooks to a file for debugging or replay:
output:
broker:
pattern: fan_out
outputs:
- sync_response: {}
- file:
path: /var/log/webhooks/${!@source}/${!timestamp_unix()}.json
codec: lines
Multiple Signature Formats
Support different signature header formats:
- mapping: |
# Try different header names
let sig = @"X-Hub-Signature-256".or(
@"X-Signature-256"
).or(
@"Stripe-Signature"
).or("")
# Handle different formats
let clean_sig = if sig.has_prefix("sha256=") {
sig
} else if sig.has_prefix("t=") {
# Stripe format: extract the signature
sig.re_find_object("v1=(?P<sig>[a-f0-9]+)").sig.or("")
} else {
sig
}
meta signature = clean_sig
Next Steps
- HTTP Server Input - Full configuration options
- Branch Processor - Conditional processing
- Switch Output - Content-based routing
- HTTP Client Output - Forward to HTTP endpoints
- Bloblang Guide - Data transformation reference