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.

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.
| Requirement | Funnel | Sume |
|---|---|---|
| Scheme | TLS only | Public HTTPS URLs only |
| Address | Public hostname for the device | Rejects localhost and private networks |
| Ports | 443, 8443 or 10000 | Port rules are not documented; use 443 |
| Setup time | DNS can take up to 10 minutes | Validated 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
- Terraform and secrets: keep the Sume API key out of state
Terraform's sensitive flag hides a value from logs but still writes it to state. How to handle a Sume API key and webhook signing secret without that leak.
- Compose 400: stack and overlay layout keys cannot be mixed
Fix compose_stack_takes_no_overlay_layout and its mirror: pick stack or overlay as operation, then send only the layout keys that belong to it.
- Timeline 400: first slot must start at 0 and take no transition
Fix timeline_must_start_at_zero and transition_on_first_segment: start video[0] at 0, use source_in to skip, put fades on slot two, then plan for free.
- TTS output_format: choose wav or mp3 for joins, captions and clips
Sume TTS returns mp3 by default. Choose wav when you will join, slice per sentence or feed lip-sync; mp3 is smaller but adds padding at every edge.
Written by Sume