To receive a webhook in Python with aiohttp, create an asynchronous POST handler, read the raw request body, authenticate it with your provider’s verification method, then parse and dispatch the event. The raw bytes must be available for signature verification before you trust any JSON field. The example below handles GitHub’s SHA-256 signature, JSON and URL-encoded deliveries, delivery IDs, malformed requests, and bounded payloads.
How an aiohttp webhook endpoint works
aiohttp is an asynchronous HTTP client/server framework for Python and asyncio. Its web server routes an incoming request to an async handler. A webhook handler normally performs this sequence:
- Read and limit the request body.
- Verify the provider’s signature against the original bytes.
- Check the content type and decode the payload.
- Use provider metadata to choose a handler and deduplicate deliveries.
- Queue or process the event, then return the status required by that provider.
An endpoint URL by itself does not prove who sent a request. Treat headers such as an event name or user agent as metadata, not authentication.
Prerequisites and installation
- Python 3 with an environment suitable for running an asyncio web service.
- aiohttp installed in that environment:
python -m pip install aiohttp. - A public HTTPS URL for the provider to call in production. Local development can use a tunnel, but the tunnel is not an authentication mechanism.
- A webhook secret configured in the provider and in your service.
The stable aiohttp web documentation page consulted for these APIs is labeled 3.14.3. Match the code to the aiohttp release you deploy, because the reference page for request semantics follows the project’s moving documentation branch.
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Scan for outdated or missing drivers - takes under a minute3Repair Windows errors before they cause bigger problems#1 Best Overall
Complete GitHub-compatible aiohttp receiver
GitHub sends an X-Hub-Signature-256 header containing an HMAC-SHA-256 hexadecimal digest when a secret is configured. The code verifies that digest over the raw body before decoding the event. It also accepts GitHub’s JSON and URL-encoded delivery modes.
import hashlib
import hmac
import json
import os
from aiohttp import web
SECRET_TEXT = os.environ.get("GITHUB_WEBHOOK_SECRET")
if not SECRET_TEXT:
raise RuntimeError("Set GITHUB_WEBHOOK_SECRET before starting the server")
SECRET = SECRET_TEXT.encode("utf-8")
def valid_github_signature(raw_body: bytes, header: str | None) -> bool:
"""Check GitHub's sha256=<hex digest> header in constant time."""
if not header or not header.startswith("sha256="):
return False
expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
supplied = header[len("sha256="):]
return hmac.compare_digest(supplied, expected)
async def receive_webhook(request: web.Request) -> web.Response:
# Read once. aiohttp caches the bytes, so parsing can follow verification.
raw_body = await request.read()
if not valid_github_signature(
raw_body, request.headers.get("X-Hub-Signature-256")
):
raise web.HTTPUnauthorized(text="Invalid webhook signature")
content_type = request.content_type
if content_type == "application/json":
try:
# request.json() enforces the JSON content type and uses the cached body.
event = await request.json()
except (web.HTTPBadRequest, ValueError):
raise web.HTTPBadRequest(text="Expected valid JSON")
elif content_type == "application/x-www-form-urlencoded":
# GitHub can be configured for URL-encoded deliveries.
form = await request.post()
event = dict(form)
else:
raise web.HTTPUnsupportedMediaType(
text="Use application/json or application/x-www-form-urlencoded"
)
delivery_id = request.headers.get("X-GitHub-Delivery")
event_name = request.headers.get("X-GitHub-Event")
if not delivery_id or not event_name:
raise web.HTTPBadRequest(text="Missing GitHub delivery headers")
# Replace this with durable idempotency and dispatch logic.
print(f"received delivery={delivery_id} event={event_name}")
print(json.dumps(event, ensure_ascii=False))
# Return only after the event is handled or durably queued.
return web.json_response({"received": True})
app = web.Application(client_max_size=25 * 1024 * 1024)
app.add_routes([web.post("/webhooks/github", receive_webhook)])
if __name__ == "__main__":
web.run_app(app, host="0.0.0.0", port=8080)
Save this as app.py, set the secret, and run python app.py. The application-level size limit is set to GitHub’s documented 25 MB payload cap; coordinate the value with any reverse proxy in front of aiohttp. GitHub says an event larger than 25 MB is not delivered.
Verify before parsing or acting
request.read() returns the original bytes and caches them. That is the representation a signature covers. Do not first parse, normalize, re-serialize, or modify JSON and then calculate a digest; whitespace and encoding changes can produce a different byte sequence. After authentication, await request.json() is convenient for an application/json request and raises a bad-request error for malformed JSON or an unexpected content type.
GitHub documents X-Hub-Signature-256 as the preferred header and identifies the older X-Hub-Signature as SHA-1. For a new GitHub integration, use the SHA-256 header. Other providers may use a different header, algorithm, encoding, timestamp, or replay window; implement the verifier described by that provider rather than assuming GitHub’s format.
Rank #2
A valid signature proves control of the configured secret, not that every field is safe. Validate the event schema, required IDs, and allowed event types after authentication. Never use X-GitHub-Event alone as proof of origin.
JSON and URL-encoded deliveries
| Provider format | aiohttp operation | Important behavior |
|---|---|---|
application/json |
await request.json() |
Checks the content type, parses JSON, and caches the body. |
application/x-www-form-urlencoded |
await request.post() |
Parses form fields; use this only when the provider is configured for URL encoding. |
Do not silently accept arbitrary content types. If a provider can send both formats, branch explicitly and test both. Multipart form data is a separate case supported by aiohttp’s form parser; apply the provider’s documented signature procedure to the raw request before interpreting fields.
Use delivery metadata for dispatch and deduplication
GitHub’s X-GitHub-Delivery value identifies a delivery globally, while X-GitHub-Event names the event type. After signature verification, use the event name as a dispatch hint and the delivery ID as an idempotency key.
Persist the delivery ID in durable storage before performing a side effect. If it already exists, return the provider-appropriate acknowledgement without repeating the side effect. The exact retry schedule and acknowledgement deadline are provider-specific, so consult the provider’s current delivery documentation instead of relying on a universal interval.
async def dispatch(event_name, delivery_id, payload, store, queue):
if await store.seen(delivery_id):
return "duplicate"
await store.record(delivery_id)
if event_name == "push":
await queue.enqueue("github.push", payload)
elif event_name == "issues":
await queue.enqueue("github.issues", payload)
else:
# Ignore events that were not subscribed to or implemented.
return "ignored"
return "queued"
Replace the in-memory print statements in the complete example with a database or durable queue. A process restart must not erase the set of deliveries already applied.
Acknowledgement and work duration
aiohttp response helpers let the handler choose the status and body; a normal JSON response defaults to status 200 unless you select another status. Whether you should return 200, 202, 204, or an error is defined by the webhook provider. Return only after work is complete when the provider requires synchronous processing. Otherwise, authenticate, validate, durably enqueue, and acknowledge quickly so long-running jobs do not hold the HTTP request open.
Do not acknowledge an event before it is safely stored if losing it would matter. Conversely, do not perform an expensive deployment, email, or payment operation inline when a queue can make retries and idempotency explicit.
Test the endpoint locally
cURL with a real HMAC signature
Sign exactly the bytes sent to aiohttp. This shell example uses a file so the signed content and transmitted content cannot drift:
Recommended Free Tools
export WEBHOOK_SECRET='replace-with-your-configured-secret'
printf '%s' '{"action":"opened","number":42}' > payload.json
signature=$(openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" payload.json | awk '{print $2}')
curl -i http://127.0.0.1:8080/webhooks/github
-H 'Content-Type: application/json'
-H 'X-GitHub-Delivery: local-test-001'
-H 'X-GitHub-Event: issues'
-H "X-Hub-Signature-256: sha256=$signature"
--data-binary @payload.json
Set GITHUB_WEBHOOK_SECRET to the same value before starting app.py. The expected successful response is a JSON body containing "received": true. A changed newline, different file, or wrong secret should produce 401.
Python client test
import hashlib
import hmac
import json
import requests
secret = b"replace-with-your-configured-secret"
body = json.dumps({"action": "opened", "number": 42}, separators=(",", ":")).encode()
digest = hmac.new(secret, body, hashlib.sha256).hexdigest()
response = requests.post(
"http://127.0.0.1:8080/webhooks/github",
data=body,
headers={
"Content-Type": "application/json",
"X-GitHub-Delivery": "python-test-001",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": f"sha256={digest}",
},
timeout=10,
)
print(response.status_code, response.text)
Node.js client test
const crypto = require('node:crypto');
const secret = 'replace-with-your-configured-secret';
const body = JSON.stringify({ action: 'opened', number: 42 });
const digest = crypto.createHmac('sha256', secret).update(body).digest('hex');
const response = await fetch('http://127.0.0.1:8080/webhooks/github', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-github-delivery': 'node-test-001',
'x-github-event': 'issues',
'x-hub-signature-256': `sha256=${digest}`
},
body
});
console.log(response.status, await response.text());
Production checklist
- Use HTTPS and keep the secret outside source control.
- Verify the raw body before parsing or dispatching.
- Reject unsupported content types and malformed payloads.
- Set a deliberate body limit in aiohttp and matching proxy limits.
- Subscribe only to event types the application handles; GitHub recommends reducing unnecessary deliveries.
- Persist delivery IDs and make side effects idempotent.
- Log delivery IDs, event names, status codes, and processing duration without logging secrets or unnecessary personal data.
- Measure queue and handler latency so acknowledgement does not race a timeout imposed by the provider.
Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 Invalid webhook signature | Wrong secret, wrong header, or bytes changed before hashing. | Hash the exact raw body with the configured secret and compare the complete sha256= value. |
| 400 Expected valid JSON | Malformed JSON or a JSON content type with a non-JSON body. | Capture the raw request for a safe local test, validate the sender’s format, and send valid JSON. |
| 415 Unsupported media type | The provider is sending URL-encoded data while the route expects JSON, or vice versa. | Configure the provider format or add an explicit request.post() branch. |
| 413/entity-too-large | The aiohttp or proxy body limit is below the event size. | Align limits with the provider’s documented cap and reject oversized requests intentionally. |
| Duplicate side effects | Redelivery or a client retry was processed twice. | Store the provider delivery ID and enforce an idempotency constraint before side effects. |
| Events appear but do nothing | Dispatch logic does not recognize the event name or required fields. | Inspect the authenticated event name, subscribe only to supported types, and validate each schema. |
Or skip the browser setup
If your webhook workflow also needs a screenshot of a URL, ScreenshotNeo is a separate website screenshot API and MCP server; it does not replace the aiohttp receiver. One GET request returns a PNG, JPEG, WebP, or PDF. The API accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result.
Use the documented API call (see the ScreenshotNeo API documentation):
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
ScreenshotNeo also provides an MCP server for AI agents such as Claude and Cursor, with take_screenshot, get_page_info, and capture_pdf tools. Its Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Every feature is available on every plan, and yearly billing gives two months free. Create a free ScreenshotNeo account to start.
Frequently asked questions
Does request.json() verify a webhook?
No. It only checks and decodes the request body. Authentication must be performed separately with the provider’s documented signature or verification mechanism.
Best Value
Can one aiohttp route receive several providers?
Yes, but keep each provider’s verification, content-type rules, headers, and dispatch code separate. A GitHub SHA-256 verifier should not be applied to another provider without confirming that provider’s protocol.
What should I do with an event I do not handle?
After authenticating it, record enough metadata for observability and return the status your provider expects. Configure subscriptions narrowly so unsupported events do not consume unnecessary requests.
Is the 25 MB limit an aiohttp limit?
No. It is GitHub’s documented webhook payload cap. aiohttp can enforce an application limit through client_max_size; proxy and server limits should be coordinated with it.
Frequently Asked Questions
Does request.json() verify a webhook?
No. It parses the body; signature or another provider-specific authentication step is still required.
Can one aiohttp route receive several providers?
Yes, if each provider has isolated verification, parsing, and dispatch rules.
What should I do with an event I do not handle?
Authenticate it, record appropriate metadata, and follow the provider’s acknowledgement policy while keeping subscriptions narrow.
Is the 25 MB limit an aiohttp limit?
No. It is GitHub’s documented payload cap; aiohttp’s configured body limit is a separate setting.
Windows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallCrashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteQuick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

