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_url to 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 in webhook_secret in 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

HeaderValue
X-Furl-Eventlink.clicked
X-Furl-Delivery-Iddlv_…. The same across retries of one delivery; use it to deduplicate.
X-Furl-Delivery-Attempt1 to 5.
X-Furl-TimestampUnix time in seconds when the attempt was signed.
X-Furl-Signaturesha256=<hex>, an HMAC-SHA256 of <timestamp>.<raw body> keyed with the webhook secret.
Request headers
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

link.clicked
{
  "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"
  }
}
FieldDescription
idThe delivery id, same as X-Furl-Delivery-Id.
eventAlways link.clicked for now.
linkThe link’s numeric id and short_code.
click.timestampWhen the click happened (RFC3339, UTC, milliseconds).
click.countryISO country code, e.g. DE. May be empty.
click.cityCity name. Often empty.
click.referrerFull referring URL, empty for direct traffic.
click.referrer_domainHost of the referrer.
click.device_typedesktop, mobile, tablet, bot or other.
click.osOperating system.
click.browserBrowser.
click.user_agentThe raw User-Agent string.

Privacy

Visitor IP addresses are never included in webhooks. 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.
Python
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)
Node.js
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

You can rotate the secret at any time in the Webhook panel or with POST /links/{code}/webhook/rotate-secret. The old secret stops working immediately, so update your receiver at the same time.

Retries and delivery guarantees

  • Retried on network errors, timeouts, 408, 429 and 5xx responses: 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, retrying or failed, 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.

webhook_receiver.py
#!/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

shell
export FURL_WEBHOOK_SECRET=whsec_...   # from the link's Webhook panel
python3 webhook_receiver.py              # listens on 127.0.0.1:8000

furl.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:

shell
sqlite3 furl_clicks.db 'SELECT clicked_at, short_code, country, referrer_domain FROM clicks ORDER BY clicked_at DESC LIMIT 10'