I’ve been playing with webhooks in Codeberg (i.e. Forgejo). Forgejo have a very nice php example webhook handler. I know that I can write it as either a python or a golang webapp but what if I don’t want to write another webapp and I just want something to process the JSON POST in the same way as the php script.

Aside from the fact that the cgi module is deprecated since python 3.13, most of the available documentation for python CGI scripts seems to concentrate on parsing form requests and rendering html. Yeah, that’s what old-school CGI was used for, but I want something just a little bit more niche. So here is my version of Forgejo’s webhook handler as a python CGI script (with a few bits added because I can’t help myself):

#!/usr/bin/python3
import hashlib
import hmac
import json
import os
import sys

CONFIG_FILE = "/etc/webhook_config.json"


def authenticate(services: dict) -> dict | None:
    auth_header = os.getenv("HTTP_AUTHORIZATION")
    if not auth_header:
        return None
    parts = auth_header.split(" ", 2)
    if len(parts) != 2:
        return None
    if parts[0].lower() != "bearer":
        return None
    return services.get(parts[1])


def main():
    if os.getenv("REQUEST_METHOD", "") != "POST":
        print("Status: 405 Method Not Allowed")
        print("Allow: POST")
        print("Content-Length: 0")
        print()
        return

    content_type = os.getenv("CONTENT_TYPE", "")
    if not content_type.startswith("application/json"):
        print("Status: 415 Unsupported Media Type")
        print("Accept-Post: application/json; charset=UTF-8")
        print("Content-Length: 0")
        print()
        return

    with open(CONFIG_FILE) as fp:
        cfg = json.load(fp)

    service = authenticate(cfg["services"])
    if not service:
        print("Status: 401 Unauthorized")
        print("WWW-Authenticate: Bearer")
        print("Content-Length: 0")
        print()
        return

    header_signature = os.getenv("HTTP_X_FORGEJO_SIGNATURE")
    if not header_signature:
        print("Status: 400 Bad Request")
        print("Content-Type: text/plain")
        print()
        print("Signature is required")
        return

    payload = sys.stdin.read()
    payload_signature = hmac.new(service["key"].encode(), payload.encode(), hashlib.sha256).hexdigest()
    if payload_signature != header_signature:
        print("Status: 403 Forbidden")
        print("Content-Type: text/plain")
        print()
        print("Signature mismatch")
        return

    body = json.loads(payload)
    ref = body.get("ref", "")
    if ref.startswith("refs/tags/v"):
        tag = ref.removeprefix("refs/tags/")
        with open(os.path.join(cfg["output_dir"], service["file"]), "w") as fp:
            fp.write(tag)

    # default status is 200 OK
    print("Content-Type: text/plain")
    print()
    print("OK")


if __name__ == "__main__":
    main()

In this script you can see how to return non-200 status codes, header-only responses, how to parse JSON from a HTTP POST request, and how to pull out a bumped version tag from the JSON webhook payload.

This script works nicely when served from the cgi-bin directory of either an Apache2 http server or a Lighttpd web server. Both have pretty good support for CGI scripts and don’t require a lot of mucking around to get going.