Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Verify Signed Webhooks Before Running Mac Jobs

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.

Pick the Figment Mac That Matches the Job

The listener itself is tiny: it parses headers, recomputes an HMAC, and dispatches. The decision is what the webhook triggers.

  • Virtual Intel macOS, $29/month — 2 vCPU, 8 GiB RAM, 80 GiB disk. Runs the listener comfortably and executes Intel-compatible toolchains, including supported Xcode 26 or earlier builds that cross-compile arm64 device and universal macOS binaries. Choose this when the triggered job is scripting, packaging, Intel builds, cross-compile signing, or outbound CI runner work. Access is via the browser console; a tunnel such as cloudflared gives the sender an inbound URL.
  • i3 Mac, $49/month — physical Intel node for jobs that need a real Intel Mac. Request-based while supply is confirmed.
  • M-series Mac, from $95/month — choose this when the webhook triggers ARM execution, Xcode 27, visionOS, or GPU-bound work. Exact chip, RAM and disk are set in the quote. See the lineup and request one on the Figment Mac hosting page.
  • Managed Build Mac, $249/month — one M-series Mac with CI setup, backups, monitoring, and 30 minutes of monthly support. Pick this if you want Figment to wire the listener, launchd job, and CI runner for you.

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.

Design the Signed-String Contract

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}

HeaderValue
X-TimestampUnix timestamp, integer seconds, UTC
X-Signaturesha256=<lowercase hex HMAC-SHA256>

Receiver steps:

  1. Read X-Timestamp and X-Signature.
  2. Reject if abs(now - timestamp) > 300 seconds.
  3. Rebuild the signed string from the raw request body bytes, not re-parsed JSON.
  4. Compute HMAC-SHA256(secret, signed_string).
  5. Compare with hmac.compare_digest (constant-time per the Python hmac module documentation).
  6. Reject on any mismatch.

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.

Generate Reusable Test Vectors

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:

VectorChangeExpected result
BaselineNoneaccept
Wrong secretsecret flippedreject
Body edited after signingextra whitespace in JSONreject
Stale timestamp10 minutes oldreject
Missing X-Signatureheader droppedreject

A launchd-Managed Python Listener

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.

Failure and Recovery Checks

  • Everything returns 401. Run the test-vector script, 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.
  • Timestamp drift. If senders sit behind flaky NTP, widen the window to 600 seconds and add a nonce cache (SQLite table keyed on signature, TTL = window) so duplicates inside the window do not re-trigger jobs.
  • Secret rotation. Accept two secrets during a rollover window. Verify against both; log which one matched.
  • Job stampedes. Each dispatch writes to a unique log path (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.

Start on Figment

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


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms