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
- 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.
- Twenty image jobs, one webhook: mode webhook plus a Python verifier
Submit 20 image requests with mode webhook, receive signed job.completed callbacks, and verify them in Python. Retries, replay window and the poll fallback.
- Unit-test a Sume job poll loop with a fake clock and no network
Inject the status reader and the sleep function to test a Sume poll loop in milliseconds: next_poll_after_seconds, backoff fallback and the client deadline.
- Upgraded your Sume plan but ratelimit-limit is still the old number?
A plan change can take up to 60 seconds to reach the per-key rate limit, because the tier is cached. Why ratelimit-limit lags, and what changes at once.
Written by Sume