Skip to content

Webhook

WebhookSource allows external systems to push messages to your tasks via HTTP requests.

Basic Usage

python
from onestep import OneStepApp, WebhookSource, MemoryQueue

app = OneStepApp("webhook-demo")
results = MemoryQueue("results")


@app.task(
    source=WebhookSource(
        path="/webhooks/github",
        methods=("POST",),
        host="127.0.0.1",
        port=8080,
    ),
    emit=results,
)
async def handle_github(ctx, event):
    return {
        "event": event["headers"].get("x-github-event"),
        "payload": event["body"],
    }


if __name__ == "__main__":
    app.run()

Request Structure

The event received by the task contains:

python
{
    "body": {...},           # Request body (parsed)
    "headers": {...},        # Request headers
    "query": {...},          # Query parameters
    "method": "POST",        # HTTP method
    "path": "/webhooks/github",  # Request path
    "client": ("127.0.0.1", 12345),  # Client address
    "received_at": 1691546688.498,  # Reception timestamp
}

Configuration Options

Basic Configuration

python
source = WebhookSource(
    path="/webhook",         # Path
    methods=("POST", "PUT"), # Allowed methods
    host="0.0.0.0",          # Listen address
    port=8080,               # Port
)

Authentication

Using Bearer Token authentication:

python
from onestep import BearerAuth

source = WebhookSource(
    path="/webhook",
    auth=BearerAuth("your-secret-token"),
)

Requests need to include the token:

bash
curl -H "Authorization: Bearer your-secret-token" \
     -X POST http://localhost:8080/webhook \
     -d '{"data": "..."}'

Request Body Parsing

python
source = WebhookSource(
    path="/webhook",
    parser="json",  # json | form | text | raw | auto
)
  • json: Parse as JSON object
  • form: Parse form data
  • text: Raw text
  • raw: Raw bytes
  • auto: Auto-select based on Content-Type (default)

Custom Response

python
from onestep import WebhookResponse

source = WebhookSource(
    path="/webhook",
    response=WebhookResponse(
        status_code=202,
        body={"received": True},
        headers={"X-Custom": "value"},
    ),
)

Multiple Webhook Routes

Multiple webhooks can share the same server:

python
github = WebhookSource(
    path="/webhooks/github",
    host="127.0.0.1",
    port=8080,
)

stripe = WebhookSource(
    path="/webhooks/stripe",
    host="127.0.0.1",
    port=8080,  # Same port
)


@app.task(source=github)
async def handle_github(ctx, event):
    print("GitHub event:", event["headers"].get("x-github-event"))


@app.task(source=stripe)
async def handle_stripe(ctx, event):
    print("Stripe event:", event["body"].get("type"))


if __name__ == "__main__":
    app.run()

Example: GitHub Webhook

python
from onestep import BearerAuth, OneStepApp, WebhookSource, MemoryQueue

app = OneStepApp("github-webhook")
events = MemoryQueue("github-events")


@app.task(
    source=WebhookSource(
        path="/webhooks/github",
        methods=("POST",),
        host="0.0.0.0",
        port=8080,
        auth=BearerAuth("your-webhook-secret"),
    ),
    emit=events,
)
async def parse_github_event(ctx, event):
    event_type = event["headers"].get("x-github-event")
    payload = event["body"]
    
    if event_type == "push":
        return {
            "type": "push",
            "repo": payload["repository"]["full_name"],
            "branch": payload["ref"],
            "commits": len(payload.get("commits", [])),
        }
    elif event_type == "pull_request":
        return {
            "type": "pull_request",
            "repo": payload["repository"]["full_name"],
            "action": payload["action"],
            "pr_number": payload["number"],
        }
    
    return {"type": event_type, "payload": payload}


@app.task(source=events)
async def process_event(ctx, event):
    print(f"Processing event: {event}")


if __name__ == "__main__":
    app.run()

Example: Slack Command

python
from onestep import OneStepApp, WebhookSource

app = OneStepApp("slack-commands")


@app.task(
    source=WebhookSource(
        path="/slack/command",
        methods=("POST",),
        parser="form",
    )
)
async def handle_slash_command(ctx, event):
    text = event["body"].get("text", "")
    user = event["body"].get("user_name", "")
    
    return {
        "response_type": "in_channel",
        "text": f"Received command: {text} (from {user})",
    }


if __name__ == "__main__":
    app.run()

YAML Configuration

yaml
resources:
  github_webhook:
    type: webhook
    path: "/webhooks/github"
    methods:
      - POST
    host: "0.0.0.0"
    port: 8080

tasks:
  - name: handle_github
    source: github_webhook
    handler:
      ref: myapp.handlers:handle_github

Production Deployment Recommendations

1. Use Reverse Proxy

Using Nginx as reverse proxy is recommended:

nginx
server {
    listen 80;
    server_name webhooks.example.com;
    
    location /webhooks/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

2. HTTPS

HTTPS is required in production. Terminate TLS at the reverse proxy layer (Nginx, Caddy, ALB, etc.) and forward to the local port where WebhookSource listens.

3. Signature Verification

Implement custom signature verification:

python
import hmac
import hashlib

@app.task(source=webhook_source)
async def handle_webhook(ctx, event):
    signature = event["headers"].get("x-signature")
    payload = event["body"]
    
    expected = hmac.new(
        b"your-secret",
        str(payload).encode(),
        hashlib.sha256
    ).hexdigest()
    
    if not hmac.compare_digest(signature, expected):
        raise Exception("Invalid signature")
    
    # Process request
    ...

4. Idempotency

Webhooks may be sent repeatedly; ensure tasks are idempotent:

python
@app.task(source=webhook_source)
async def handle_webhook(ctx, event):
    event_id = event["headers"].get("x-event-id")
    
    # Check if already processed
    if await ctx.state.get(f"event:{event_id}"):
        return {"status": "duplicate"}
    
    # Process event
    result = await process_event(event)
    
    # Mark as processed
    await ctx.state.set(f"event:{event_id}", True)
    
    return result

Released under the MIT License.