Skip to main content

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

  1. Receives HTTP POST requests at multiple webhook endpoints
  2. Validates webhook signatures using HMAC-SHA256
  3. Parses JSON payloads and extracts event type
  4. Routes events to different outputs based on type
  5. 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