← Webhook localhost by framework
FastAPI webhook localhost
Local run command
Uvicorn’s documented default is 8000, not Next.js’s 3000. Mercur will 502 if the Endpoint still says 3000. Bind IPv4 to match the agent:
uvicorn main:app --reload --host 127.0.0.1 --port 8000--reload is the dev reloader. It is not a production server. Endpoint Local port is 8000. uvicorn main:app without --host already uses 127.0.0.1; passing --host 0.0.0.0 still works because IPv4 stays open. --host :: without IPv4 is the 502 case.
Webhook route
Starlette reads the ASGI body once. Declare request: Request and await request.body() before json.loads. A signature payload: dict or event: GitHubPush tells FastAPI to parse JSON for you — that is the HMAC bug.
Path {findFrameworkArticle("fastapi").webhookPath} must appear in the GitHub payload URL.
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
app = FastAPI()
def github_signature_256(secret: str, raw_body: bytes) -> str:
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
def signatures_match(expected: str, actual: str) -> bool:
if len(expected) != len(actual):
return False
return hmac.compare_digest(expected, actual)
@app.post("/webhooks/github")
async def github_webhook(
request: Request,
x_hub_signature_256: str | None = Header(default=None),
x_github_event: str | None = Header(default=None),
x_github_delivery: str | None = Header(default=None),
):
# payload: dict / Body(...) parses JSON first. GitHub signed the raw POST bytes.
raw_body = await request.body()
secret = os.environ.get("GITHUB_WEBHOOK_SECRET")
if not secret:
raise HTTPException(status_code=500, detail="GITHUB_WEBHOOK_SECRET is not set")
if not x_hub_signature_256 or not signatures_match(
github_signature_256(secret, raw_body), x_hub_signature_256
):
raise HTTPException(status_code=401, detail="invalid signature")
payload = json.loads(raw_body)
return JSONResponse(
{"ok": True, "event": x_github_event, "delivery": x_github_delivery, "ref": payload.get("ref")},
status_code=202,
)
APIRouter(prefix="/webhooks") plus @router.post("/github") is the same path. If the provider URL is /github only, FastAPI 404s.
Raw body and signatures
Set GITHUB_WEBHOOK_SECRET to the same string as the GitHub webhook secret. The snippet hashes raw_body with HMAC-SHA256 and prefixes sha256=, then hmac.compare_digest. Header name X-Hub-Signature-256 is exposed as x_hub_signature_256 in FastAPI’s Header().
Do not use Body(...) or payload: dict on this function. Those dependencies consume the stream; a later await request.body() is empty and the digest is wrong.
Stripe users: stripe.Webhook.construct_event also needs the raw bytes from await request.body(), not a Pydantic model. Same rule, different header (Stripe-Signature).
Webhook signature verification failed covers GitHub vs Stripe vs Telegram (Telegram is not HMAC).
Connect localhost
- HTTP Endpoint, Local port
{findFrameworkArticle("fastapi").localPort}— not 3000. mercur author the macOS app.- Start forwarding:
mercur start ep-webhooks- GitHub payload URL
https://ep-webhooks.mercur.sh/webhooks/github.
Forwarding needs a connected agent — the macOS app or the npm package @mercur_dev/cli.
If you copied a Next.js Endpoint, it still points at 3000. Edit Local port to 8000 or GitHub will see 502.
Expected incoming request
Uvicorn access log should show POST /webhooks/github 202 after a GitHub ping:
POST {findFrameworkArticle("fastapi").webhookPath}X-Hub-Signature-256X-GitHub-Event: pingHost: ep-webhooks.mercur.sh- Body starts with
{"zen":
Starlette TrustedHostMiddleware is off by default. If you enable it with allowed_hosts=["localhost"], Mercur’s public Host header fails closed (400/400-class). Allow *.mercur.sh or disable the middleware in local webhook work.
Guest inspector: 204, no Uvicorn log line for your app.
Inspect and replay
Mercur history should show {findFrameworkArticle("fastapi").webhookPath} and the GitHub headers. Capture limit 256 KB.
Retry after you switch from payload: dict to await request.body(). Proxy mode, agent connected. A FastAPI BackgroundTasks job that runs after the 202 is fine; work awaited before the return still counts toward GitHub’s 10 second limit (webhook timeout).
Inspect the request Connect localhost
Provider examples
- Test GitHub webhooks locally —
ping, payload URL, secret - Test Stripe webhooks locally — same
request.body()rule if you add a Stripe route - Webhook 502 localhost — port 8000 vs 3000
Framework troubleshooting
- HMAC always invalid —
payload: dictor you calledawait request.json()first. Starlette cannot rewind. - 422 Unprocessable Entity — a Pydantic model rejected the payload before your handler. That is FastAPI, not Mercur. GitHub then retries.
- 502 on 3000 — Endpoint still on Next.js defaults.
curl -i http://127.0.0.1:8000/webhooks/github. - Lifespan hang — a
@app.on_event("startup")that blocks (open a DB that is down) makes every POST time out. ModuleNotFoundError: uvicorn— the process Mercur dials is not this virtualenv. The agent does not activate.venvfor you.
Related
- Django webhook localhost
- Next.js webhook localhost
- Express webhook localhost
- Webhook signature verification failed
- Webhook 502 localhost
- Webhook timeout
- Webhook 500
- Test GitHub webhooks locally
- Test Stripe webhooks locally
- How to test webhooks
- Expose a local service
- CLI agent: forward localhost with npm
- Troubleshooting
- Online webhook inspector