Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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:

  1. Read and limit the request body.
  2. Verify the provider’s signature against the original bytes.
  3. Check the content type and decode the payload.
  4. Use provider metadata to choose a handler and deduplicate deliveries.
  5. 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.