Tailscale Funnel for Sume webhooks: a public HTTPS URL for localhost

Sume webhook URLs must be public HTTPS, so localhost is rejected. Tailscale Funnel gives your dev machine one, plus a receiver that refuses an empty secret.

4 min readSume
All posts

Run your receiver on a local port, then run tailscale funnel 3000 and pass the resulting https:// address as webhook_url with mode: "webhook". Sume accepts only public HTTPS URLs and rejects localhost, private-network and non-HTTPS addresses, so a plain http://localhost:3000 fails at submit, and a Funnel address satisfies the rule.

The Tailscale Funnel page (read 2026-10-10) is the source for the Tailscale side of this post; the Sume side comes from the Webhooks docs.

What Funnel gives you and what it limits

Per the Tailscale page, Funnel routes internet traffic to a service on a tailnet device, supports ports 443, 8443 and 10000, serves TLS only, and needs MagicDNS and Tailscale v1.38.3 or newer. It also states that bandwidth limits exist and are not configurable, and that DNS propagation for a new Funnel hostname can take up to 10 minutes. For a webhook receiver, which gets small JSON bodies, those limits do not matter. The 10-minute DNS wait does: start Funnel before your first test, not in the middle of it.

Funnel facts against Sume's URL rule (Tailscale page read 2026-10-10)
RequirementFunnelSume
SchemeTLS onlyPublic HTTPS URLs only
AddressPublic hostname for the deviceRejects localhost and private networks
Ports443, 8443 or 10000Port rules are not documented; use 443
Setup timeDNS can take up to 10 minutesValidated at submit

A receiver that refuses to run without a secret

Sume signs the raw body with HMAC SHA-256 over <timestamp>.<raw_body>, and the headers are x-sume-webhook-timestamp and x-sume-webhook-signature in the form sume-v1=<hex>. During a secret rotation the signature header carries several comma-separated entries, and any match is valid. The sample checks the 5-minute window, compares every entry with hmac.compare_digest, deduplicates on job_id, and exits at startup if the secret is empty. I tested it with one valid, one forged and one empty-secret run.

import hashlib, hmac, json, os, sys, time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
if not SECRET:
    sys.exit("refusing to start: SUME_COM_WEBHOOK_SIGNING_SECRET is empty")
SEEN = set()  # job_id is the idempotency key; use a database in real code
def valid(body: bytes, ts: str, header: str) -> bool:
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(SECRET.encode(), ts.encode() + b"." + body, hashlib.sha256)
    want = "sume-v1=" + mac.hexdigest()
    return any(hmac.compare_digest(p.strip(), want) for p in header.split(","))
class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("content-length", 0)))
        h = self.headers
        ok = valid(body, h.get("x-sume-webhook-timestamp", ""),
                   h.get("x-sume-webhook-signature", ""))
        self.send_response(204 if ok else 401)
        self.end_headers()
        if ok:
            event = json.loads(body)
            if event.get("job_id") not in SEEN:
                SEEN.add(event.get("job_id"))
                print(event["event"], event.get("job_id"), flush=True)
HTTPServer(("127.0.0.1", 3000), Handler).serve_forever()

Wire it up

Read the signing secret from the Webhooks tab of the dashboard, or with an API key that has account:read from GET /v1/webhooks/signing-secret. Export it as SUME_COM_WEBHOOK_SIGNING_SECRET, start the receiver, run tailscale funnel 3000, and submit with the Funnel address as webhook_url.

POST /v1/webhooks/test-deliveries sends a dummy signed webhook.test event to a URL you type, which proves your tunnel and signature code before you spend on a real job. It never replays a real job.

Keep the poll fallback

Sume tries a delivery up to 10 times, 30 seconds apart by default, with a 10-second timeout per attempt. A laptop that sleeps through that window loses the event, and the docs say delivery is an optimization, never the only recovery path. Keep polling status_url for jobs whose webhook never arrives, and use POST /v1/jobs/{job_id}/webhook/redeliver once the tunnel is back. Funnel is for development; a production receiver should sit on a stable host.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume