Integrations
Webhooks
Give a link a webhook URL and furl.io sends a signed JSON POST to it for every click, with retries and a delivery log.
Setting up
Available on Pro and higher (see plans and limits). If your plan stops including webhooks, the link keeps its webhook settings but no requests are sent until you upgrade again.
- In the dashboard, set Webhook URL when creating or editing a link, or send
webhook_urlto the API. - The URL must be a public
https://endpoint. Private, internal and loopback addresses are refused. - A signing secret (
whsec_…) is generated when the webhook URL is first set. Find it in the link’s Webhook panel, or inwebhook_secretin the API.
The request
One request is sent for every click on the link (event link.clicked). Details:
- Method
POST,Content-Type: application/json,User-Agent: furl-webhooks/1.0. - Timeout 10 seconds. Redirects are not followed — point the URL at the final endpoint.
- Any 2xx response counts as success. The response body is ignored.
Headers
| Header | Value |
|---|---|
X-Furl-Event | link.clicked |
X-Furl-Delivery-Id | dlv_…. The same across retries of one delivery; use it to deduplicate. |
X-Furl-Delivery-Attempt | 1 to 5. |
X-Furl-Timestamp | Unix time in seconds when the attempt was signed. |
X-Furl-Signature | sha256=<hex>, an HMAC-SHA256 of <timestamp>.<raw body> keyed with the webhook secret. |
POST /furl HTTP/1.1
Content-Type: application/json
User-Agent: furl-webhooks/1.0
X-Furl-Event: link.clicked
X-Furl-Delivery-Id: dlv_4b7dd7351ffd59362e86a9e5
X-Furl-Delivery-Attempt: 1
X-Furl-Timestamp: 1791203696
X-Furl-Signature: sha256=5d41402abc4b2a76b9719d911017c592...Payload
{
"id": "dlv_4b7dd7351ffd59362e86a9e5",
"event": "link.clicked",
"link": { "id": 42, "short_code": "aB9x" },
"click": {
"timestamp": "2026-10-05T12:34:56.789Z",
"country": "DE",
"city": "Berlin",
"referrer": "https://news.ycombinator.com/",
"referrer_domain": "news.ycombinator.com",
"device_type": "desktop",
"os": "macOS",
"browser": "Safari",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
}
}| Field | Description |
|---|---|
id | The delivery id, same as X-Furl-Delivery-Id. |
event | Always link.clicked for now. |
link | The link’s numeric id and short_code. |
click.timestamp | When the click happened (RFC3339, UTC, milliseconds). |
click.country | ISO country code, e.g. DE. May be empty. |
click.city | City name. Often empty. |
click.referrer | Full referring URL, empty for direct traffic. |
click.referrer_domain | Host of the referrer. |
click.device_type | desktop, mobile, tablet, bot or other. |
click.os | Operating system. |
click.browser | Browser. |
click.user_agent | The raw User-Agent string. |
Privacy
city (and sometimes country) may be empty.Verifying signatures
Verify every request before trusting it:
- Recompute
HMAC-SHA256(secret, timestamp + "." + raw_body)over the raw bytes you received — not re-serialized JSON — and hex-encode it. - Compare it with the value after
sha256=using a constant-time comparison. - Reject timestamps older than about 5 minutes to block replays.
import hashlib, hmac, time
def verify(secret: str, headers, raw_body: bytes, max_age: int = 300) -> bool:
ts = headers.get("X-Furl-Timestamp", "")
sig = headers.get("X-Furl-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > max_age:
return False # missing or stale timestamp: possible replay
expected = "sha256=" + hmac.new(
secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)const crypto = require("node:crypto");
// rawBody must be the exact bytes received (e.g. express.raw({ type: "application/json" })).
function verify(secret, headers, rawBody, maxAgeSeconds = 300) {
const ts = headers["x-furl-timestamp"] ?? "";
const sig = headers["x-furl-signature"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > maxAgeSeconds) {
return false; // missing or stale timestamp: possible replay
}
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(ts + ".").update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Rotating the secret
Retries and delivery guarantees
- Retried on network errors, timeouts,
408,429and5xxresponses: up to 5 attempts in total, waiting about 1 s, 2 s, 4 s and 8 s between them (±20% jitter). - Other 4xx responses are not retried — fix the receiver and the next click will be delivered.
- Every attempt appears in the link’s webhook log (dashboard and API) with status
delivered,retryingorfailed, the response code, duration and payload. - Delivery is at-least-once: the same delivery can arrive more than once, so deduplicate on
X-Furl-Delivery-Id. Retries in progress can be lost if the furl.io worker restarts. - Respond quickly with a 2xx and do slow work afterwards, to stay well inside the 10-second timeout.
Example: store clicks in SQLite (Python)
A complete receiver using only the Python standard library (3.8+). It verifies the signature and timestamp, deduplicates on the delivery id, and stores each click in a local SQLite database.
#!/usr/bin/env python3
"""
furl.io webhook receiver -> SQLite.
Receives `link.clicked` webhooks from furl.io, verifies their HMAC-SHA256
signature, and stores each click in a local SQLite database. Python 3.8+,
standard library only (no pip install needed).
Usage:
export FURL_WEBHOOK_SECRET='whsec_...' # from the link's Webhook panel
python3 webhook_receiver.py # listens on 127.0.0.1:8000
Environment variables:
FURL_WEBHOOK_SECRET required the link's signing secret (whsec_...)
FURL_DB_PATH optional SQLite file (default: furl_clicks.db)
HOST / PORT optional bind address (default: 127.0.0.1 / 8000)
FURL_MAX_SKEW optional max timestamp age in seconds (default: 300)
furl.io only delivers to public https:// URLs, so in production run this
behind a TLS-terminating reverse proxy (nginx, Caddy, a tunnel, ...) and use
that https URL as the link's webhook URL.
"""
import hashlib
import hmac
import json
import logging
import os
import sqlite3
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRET = os.environ.get("FURL_WEBHOOK_SECRET", "")
DB_PATH = os.environ.get("FURL_DB_PATH", "furl_clicks.db")
HOST = os.environ.get("HOST", "127.0.0.1")
PORT = int(os.environ.get("PORT", "8000"))
MAX_SKEW = int(os.environ.get("FURL_MAX_SKEW", "300"))
MAX_BODY = 64 * 1024
log = logging.getLogger("furl-webhooks")
SCHEMA = """
CREATE TABLE IF NOT EXISTS clicks (
delivery_id TEXT PRIMARY KEY, -- stable across retries: dedup key
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
link_id INTEGER NOT NULL,
short_code TEXT NOT NULL,
clicked_at TEXT NOT NULL,
country TEXT,
city TEXT,
referrer TEXT,
referrer_domain TEXT,
device_type TEXT,
os TEXT,
browser TEXT,
user_agent TEXT,
raw_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clicks_code_time ON clicks (short_code, clicked_at);
"""
_db_lock = threading.Lock()
def open_db(path):
conn = sqlite3.connect(path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL") # readers don't block the writer
conn.executescript(SCHEMA)
return conn
def verify_signature(secret, timestamp, body, signature):
"""X-Furl-Signature = "sha256=" + hex(HMAC_SHA256(secret, "<timestamp>." + body))."""
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature) # constant-time compare
def save_click(conn, payload, raw):
link, click = payload["link"], payload["click"]
with _db_lock, conn:
cur = conn.execute(
"""INSERT OR IGNORE INTO clicks
(delivery_id, link_id, short_code, clicked_at, country, city, referrer,
referrer_domain, device_type, os, browser, user_agent, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
payload["id"], link["id"], link["short_code"], click["timestamp"],
click.get("country"), click.get("city"), click.get("referrer"),
click.get("referrer_domain"), click.get("device_type"), click.get("os"),
click.get("browser"), click.get("user_agent"), raw.decode("utf-8"),
),
)
return cur.rowcount == 1 # False -> duplicate (a retry we already stored)
class Handler(BaseHTTPRequestHandler):
server_version = "furl-webhook-receiver/1.0"
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
if length <= 0 or length > MAX_BODY:
return self._reply(413 if length > MAX_BODY else 400, "bad body size")
body = self.rfile.read(length)
ts = self.headers.get("X-Furl-Timestamp", "")
sig = self.headers.get("X-Furl-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > MAX_SKEW:
return self._reply(400, "stale or missing timestamp") # replay protection
if not verify_signature(SECRET, ts, body, sig):
return self._reply(401, "invalid signature")
try:
payload = json.loads(body)
except ValueError:
return self._reply(400, "invalid json")
if payload.get("event") != "link.clicked":
return self._reply(204) # acknowledge event types we don't handle
try:
inserted = save_click(self.server.db, payload, body)
except (KeyError, sqlite3.Error) as exc:
log.error("store failed: %s", exc)
return self._reply(500, "store failed") # 5xx -> furl.io retries
log.info("%s %s /%s attempt=%s",
"stored" if inserted else "duplicate", payload["id"],
payload["link"]["short_code"], self.headers.get("X-Furl-Delivery-Attempt"))
self._reply(204) # any 2xx marks the delivery successful
def _reply(self, code, message=""):
data = message.encode()
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt, *args): # route http.server logs through logging
log.debug(fmt, *args)
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
if not SECRET.startswith("whsec_"):
sys.exit("Set FURL_WEBHOOK_SECRET to the link's signing secret (whsec_...).")
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.db = open_db(DB_PATH)
log.info("listening on http://%s:%d, saving to %s", HOST, PORT, DB_PATH)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.db.close()
if __name__ == "__main__":
main()
Run it
export FURL_WEBHOOK_SECRET=whsec_... # from the link's Webhook panel
python3 webhook_receiver.py # listens on 127.0.0.1:8000furl.io only delivers to public https:// URLs, so put a public HTTPS URL in front of the receiver — a TLS-terminating reverse proxy (nginx, Caddy) or a tunnel — forwarding to 127.0.0.1:8000. Use that URL as the link’s webhook URL. HOST, PORT, FURL_DB_PATH and FURL_MAX_SKEW are optional environment variables; see the top of the file.
Click your short link, then look at the stored clicks:
sqlite3 furl_clicks.db 'SELECT clicked_at, short_code, country, referrer_domain FROM clicks ORDER BY clicked_at DESC LIMIT 10'