
If your hosted Mac launches a job for every webhook it receives, one captured request becomes an unlimited replay. HMAC-SHA256 signatures stop forgery and a short timestamp window stops replay. This page gives you the signed-string contract, a runnable test-vector generator, a Python listener you can drop under launchd, and a direct recommendation for which Figment Mac to put it on.
The listener itself is tiny: it parses headers, recomputes an HMAC, and dispatches. The decision is what the webhook triggers.
cloudflared gives the sender an inbound URL.If the job your webhook kicks off is ARM-only or needs Xcode 27, don't co-locate the listener on a $29 Intel VM only to proxy everything — put the listener on the same M-series host that runs the work and skip a hop.
Agree on the signed string before writing code, and version it. The sender and receiver must recompute the identical byte sequence.
Signed string: {timestamp}.{raw_request_body_bytes}
| Header | Value |
|---|---|
X-Timestamp | Unix timestamp, integer seconds, UTC |
X-Signature | sha256=<lowercase hex HMAC-SHA256> |
Receiver steps:
X-Timestamp and X-Signature.abs(now - timestamp) > 300 seconds.HMAC-SHA256(secret, signed_string).hmac.compare_digest (constant-time per the Python hmac module documentation).Any JSON reformat — key reorder, whitespace change, number normalisation — produces different bytes and a different HMAC. Read the body once into a bytes buffer and sign that exact buffer on the sender.
A fixed vector pair catches almost every integration bug (wrong key encoding, missing newline, hex vs base64, body re-serialised). Run this on any machine with openssl:
#!/usr/bin/env bash
set -euo pipefail
SECRET="test-secret-01"
TIMESTAMP="1731240000"
BODY='{"event":"build","ref":"main"}'
SIGNED_STRING="${TIMESTAMP}.${BODY}"
SIG=$(printf '%s' "${SIGNED_STRING}" \
| openssl dgst -sha256 -hmac "${SECRET}" \
| awk '{print "sha256=" $2}')
printf 'Secret: %s\n' "${SECRET}"
printf 'Timestamp: %s\n' "${TIMESTAMP}"
printf 'Body: %s\n' "${BODY}"
printf 'Signed string: %s\n' "${SIGNED_STRING}"
printf 'Signature: %s\n' "${SIG}"
Treat the printed signature as an unexecuted example — record whatever your own run produces and pin that as the golden value in your test suite. Build at least these cases:
| Vector | Change | Expected result |
|---|---|---|
| Baseline | None | accept |
| Wrong secret | secret flipped | reject |
| Body edited after signing | extra whitespace in JSON | reject |
| Stale timestamp | 10 minutes old | reject |
Missing X-Signature | header dropped | reject |
The listener uses only the standard library. hmac.compare_digest is constant-time and hmac.new(key, msg, digestmod='sha256') matches the documented signature in the Python hmac module.
#!/usr/bin/env python3
"""webhook-listener.py — verify signed webhooks before dispatching Mac jobs."""
import hashlib, hmac, http.server, json, os, subprocess, time
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
REPLAY_WINDOW_SECONDS = 300
JOB_SCRIPT = os.environ.get("JOB_SCRIPT", "/usr/local/bin/run-mac-job.sh")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8765"))
if not WEBHOOK_SECRET:
raise RuntimeError("WEBHOOK_SECRET is required")
def verify(raw_body: bytes, ts_str: str, received_sig: str) -> bool:
try:
ts = int(ts_str)
except (ValueError, TypeError):
return False
if abs(time.time() - ts) > REPLAY_WINDOW_SECONDS:
return False
signed = f"{ts}.".encode() + raw_body
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), signed, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received_sig or "")
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length)
ts = self.headers.get("X-Timestamp", "")
sig = self.headers.get("X-Signature", "")
if not verify(raw, ts, sig):
self.send_response(401); self.end_headers(); return
try:
payload = json.loads(raw)
except json.JSONDecodeError:
self.send_response(400); self.end_headers(); return
event = payload.get("event", "unknown")
log_path = (
f"/tmp/job-{int(time.time())}-{os.getpid()}-"
f"{os.urandom(4).hex()}.log"
)
with open(log_path, "w") as fh:
subprocess.Popen(
[JOB_SCRIPT, event], stdout=fh,
stderr=subprocess.STDOUT, close_fds=True,
)
self.send_response(202)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"accepted": True, "event": event}).encode())
if __name__ == "__main__":
http.server.HTTPServer(("127.0.0.1", LISTEN_PORT), Handler).serve_forever()
Keep WEBHOOK_SECRET in the Keychain or your CI secret manager and fetch it at launchd startup — do not commit the plist with a real secret and do not dump the whole environment to disk. A minimal user agent, with the plist keys documented in Apple's launchd jobs reference:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.example.webhook-listener</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>WEBHOOK_SECRET="$(security find-generic-password -a agent -s webhook-secret -w)" exec /usr/bin/python3 /usr/local/bin/webhook-listener.py</string>
</array>
<key>KeepAlive</key> <true/>
<key>StandardOutPath</key> <string>/Users/agent/Library/Logs/webhook.log</string>
<key>StandardErrorPath</key><string>/Users/agent/Library/Logs/webhook.err</string>
</dict>
</plist>
The listener binds to 127.0.0.1; your tunnel (cloudflared, ngrok) or reverse proxy forwards the public URL to it. On the $29 VM, the tunnel is the only inbound path. On an M-series host, confirm public ingress in the configuration quote.
curl the listener with the printed values, and compare the first 64 bytes of raw_body on both sides. Nine times out of ten the sender re-serialised the JSON after signing.time.pid.random); collisions within a second are therefore safe. If the job itself is non-idempotent, deduplicate on the webhook's own event id before Popen.For most webhook-driven automations the $29 virtual Intel macOS plan runs the listener and the dispatched job together. Move to an M-series plan when the triggered job needs ARM execution, Xcode 27, or GPU. Pick Managed Build Mac if you want Figment to install the launchd job, wire the tunnel, and monitor the queue for you.
Request a hosted Mac — /mac-hosting/start
Related guides: Job checkpoints on a hosted Mac · Download completion detection on a hosted Mac · GitHub Actions self-hosted runner on Mac