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.