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:

# 1. Download the pipeline and check it offline
curl -sSLo pipeline.yaml \
https://docs.expanso.io/examples/http-webhook.yaml
expanso-edge validate pipeline.yaml

# 2. Start a local edge agent (its state stays in ./.edge)
expanso-edge run --local --data-dir ./.edge

# 3. In a second terminal, deploy the pipeline to it as a job
export EXPANSO_CLI_CONFIG_DIR="$PWD/.expanso-cli"
{ printf 'name: http-webhook\ntype: pipeline\nconfig:\n'
sed 's/^/ /' pipeline.yaml; } |
expanso-cli --endpoint http://localhost:9010 job deploy -

This runs on your machine with no account. Step 3 wraps the pipeline in the name, type and config fields that a job needs, and a stdout output writes to the agent's terminal. If the pipeline reads environment variables, export them in the terminal where you start the agent. See Local Mode for more, or Deploy to Cloud to run it on managed nodes.

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 = this
root.status = if this.signature_valid == false { "401" } else { "200" }
root.error = if this.signature_valid == false { "Invalid signature" } else { deleted() }

# 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 in the agent's terminal
export WEBHOOK_SECRET="your-secret-here"
export GITHUB_EVENTS_URL="http://localhost:9000/events"
export STRIPE_EVENTS_URL="http://localhost:9001/events"

# Start a local edge agent in the same terminal
expanso-edge run --local --data-dir ./.edge

Then deploy the pipeline to the agent from a second terminal, as in step 3 of Download & Run.

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​