Get startedDashboard

Webhooks

Paid plans

Receive a signed notice at your own URL each time a refresh changes the data, then read the change feed from your saved cursor.

View llms.txt

A webhook is a signed POST to your own URL, sent each time a refresh changes the data the API serves. The event names the datasets that changed and counts their records. It carries no data. Read the change feed from your saved cursor to learn what changed.

Webhooks are available on Max and Redistribution plans.

Set up a webhook

Open the dashboard, paste your https URL under Webhooks and press Add. Copy the signing secret it shows, which starts with whsec_ and is shown only once. Press Test to send a webhook.test event and see your server's answer.

Receive the event

A receiver checks the signature, replies 200 and then reads the change feed in the background, from the cursor it saved last time. Reply before reading the feed. A reply that takes longer than 10 seconds counts as a failed delivery and is retried.

The two receivers below use the official Standard Webhooks libraries. Each reads the signing secret from XFINLINK_WEBHOOK_SECRET and your API key from XFINLINK_API_KEY, keeps the cursor in xfinlink_cursor.txt, runs one sync at a time and listens on port 8000. Serve the receiver at the https URL you added on the dashboard.

Python

The Python receiver runs sync.py, the loop under Request examples on the change feed page, in a background thread. Save both files in one folder.

Terminal
pip install standardwebhooks flask xfinlink
export XFINLINK_WEBHOOK_SECRET=whsec_YOUR_SECRET XFINLINK_API_KEY=YOUR_API_KEY
python receiver.py
receiver.py
import os, subprocess, sys, threading
from flask import Flask, request
from standardwebhooks import Webhook

app = Flask(__name__)
hook = Webhook(os.environ["XFINLINK_WEBHOOK_SECRET"])
lock = threading.Lock()

def sync():
    with lock:  # one sync at a time
        subprocess.run([sys.executable, "sync.py"], check=True)

@app.post("/xfinlink")
def receive():
    try:
        hook.verify(request.get_data(), request.headers)
    except Exception:
        return "", 400
    threading.Thread(target=sync).start()
    return "", 200

if __name__ == "__main__":
    app.run(port=8000)

Node

The Node receiver runs sync.js, the same three steps in Node, as a promise it does not wait for. Save both files in one folder. Node 18 or later runs them.

Terminal
npm install standardwebhooks
export XFINLINK_WEBHOOK_SECRET=whsec_YOUR_SECRET XFINLINK_API_KEY=YOUR_API_KEY
node receiver.js
receiver.js
const http = require("node:http");
const { Webhook } = require("standardwebhooks");
const sync = require("./sync");

const hook = new Webhook(process.env.XFINLINK_WEBHOOK_SECRET);
let queue = Promise.resolve();

http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    try {
      hook.verify(Buffer.concat(chunks), req.headers);
    } catch {
      return res.writeHead(400).end();
    }
    res.writeHead(200).end();
    queue = queue.then(sync).catch(console.error);  // one sync at a time, not awaited
  });
}).listen(8000);
sync.js
const fs = require("node:fs");

const SAVED = "xfinlink_cursor.txt";
const FEED = "https://api.xfinlink.com/v1/changes";
async function pullEverything() {}                   // your full pull, stored by entity_id, returning meta.change_cursor from its first response
async function replace(record, pages) {}             // delete the stored rows of the record's id from start to end, then store the rows in pages
                                                     // a null end runs through the latest date, and a null start and end covers every date
                                                     // pages is null when url is null: pull the dataset from start to end yourself
async function keptRosters(index) { return []; }     // the as_of dates of the past rosters you keep for the index
async function replaceRoster(index, asOf, pages) {}  // store the roster on asOf, null for the current one, in place of the one you hold

async function get(url) {
  const res = await fetch(url, { headers: { "X-API-Key": process.env.XFINLINK_API_KEY } });
  if (!res.ok && res.status !== 410) throw new Error(`${url} answered ${res.status}`);
  return res.json();
}

async function fetchPages(url) {
  const out = [];
  for (let page = url; page; ) {
    const body = await get(page);
    out.push(body);
    const meta = body.meta ?? {};
    const cursor = meta.next_cursor ?? body.next_cursor;
    const next = new URL(url);
    if (cursor) next.searchParams.set("cursor", cursor);
    else if (meta.has_more) next.searchParams.set("offset", out.reduce((n, b) => n + b.meta.count, 0));
    page = cursor || meta.has_more ? next.href : null;
  }
  return out;
}

async function sync() {
  if (!fs.existsSync(SAVED)) fs.writeFileSync(SAVED, await pullEverything());
  for (let more = true; more; ) {
    const feed = await get(`${FEED}?since=${encodeURIComponent(fs.readFileSync(SAVED, "utf8"))}`);
    if (feed.error === "cursor_expired") {
      await pullEverything();
      fs.writeFileSync(SAVED, feed.meta.latest_cursor);
      return;
    }
    for (const r of feed.data) {
      await replace(r, r.url ? await fetchPages(r.url) : null);
      if (r.scope !== "index") continue;
      for (const asOf of [null, ...(await keptRosters(r.index))]) {
        const roster = `https://api.xfinlink.com/v1/index/${r.index}${asOf ? `?as_of=${asOf}` : ""}`;
        await replaceRoster(r.index, asOf, await fetchPages(roster));
      }
    }
    fs.writeFileSync(SAVED, feed.meta.next_cursor);
    more = feed.meta.has_more;
  }
}

module.exports = sync;
if (require.main === module) sync();

pullEverything, replace, keptRosters and replaceRoster stand for your own storage code, and follow the rules under Re-pull by scope on the change feed page. pullEverything stores rows by entity_id, not by ticker, and returns the meta.change_cursor of the first response of the pull. fetchPages reads every page of a re-pull, by cursor, or by offset on the index endpoints. Run node sync.js on a schedule to sync without webhooks.

Events

  • data.changed is sent once for each refresh that changed served data. A refresh that changed nothing sends no event.
  • webhook.test is sent when you press Test on the dashboard.
fieldtypeunitnullabledescription
idstrnonenoThe event's id, evt_ followed by 24 hexadecimal characters. Equal to the webhook-id header.
typestrnonenodata.changed or webhook.test.
createdstrUTC timestamp, YYYY-MM-DDTHH:MM:SSZnoFor data.changed, when the refresh's records were published to the feed. For webhook.test, when the test was sent.
run_idstrnonenoThe refresh run the event announces. data.changed only.
datasetslist[object]nonenoOne entry per dataset the refresh changed, sorted by name. Each entry holds dataset and the number of records of each kind, added, changed, removed and rebuild. data.changed only.
cursor_fromstrnonenoSent as since, it returns this refresh's records first. data.changed only.
cursor_tostrnonenoThe cursor of this refresh's last record. data.changed only.
changes_urlstrnonenohttps://api.xfinlink.com/v1/changes. data.changed only.

A data.changed event reads like this.

request body
{
  "id": "evt_d893e77f73f99b23cd56df29",
  "type": "data.changed",
  "created": "2026-09-30T22:03:31Z",
  "run_id": "prices_patch-20260930-214512",
  "datasets": [
    {"dataset": "price_adjustments", "added": 1, "changed": 0, "removed": 0, "rebuild": 0},
    {"dataset": "prices", "added": 1, "changed": 1, "removed": 0, "rebuild": 0}
  ],
  "cursor_from": "MTc5MDgwNTgxMTI1MHw0ODIwOQ==",
  "cursor_to": "MTc5MDgwNTgxMTI1MHw0ODIxMg==",
  "changes_url": "https://api.xfinlink.com/v1/changes"
}

A webhook.test event carries three fields.

request body
{"id": "evt_3f9a1c27b4d05e6f8a9b0c1d", "type": "webhook.test", "created": "2026-10-02T09:14:05Z"}

Read the feed from your own saved cursor, not from cursor_from. Your saved cursor also covers refreshes whose events were late or missed.

Headers and signature

fieldtypeunitnullabledescription
webhook-idstrnonenoThe event's id. The same on every attempt and every resend of one event, so it identifies duplicates.
webhook-timestampstrUnix secondsnoWhen this attempt was signed.
webhook-signaturestrnonenoOne or more signatures, separated by spaces, each v1, followed by a base64 HMAC-SHA256. Two appear for 24 hours after a secret rotation, one per secret.
content-typestrnonenoapplication/json.
user-agentstrnonenoxfinlink-webhooks/1.

The signature is an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your secret after whsec_. The scheme follows Standard Webhooks, so any Standard Webhooks library verifies it with your secret.

Verify every request before you act on it. Use the raw body bytes as received, because JSON that is parsed and serialized again no longer matches the signature. Reject a timestamp more than 5 minutes from your own clock. Where a library cannot be added, this function does all three with the Python standard library.

verify.py
import base64
import hashlib
import hmac
import time

def verify(secret, headers, body):
    """secret is the whsec_ value from the dashboard. body is the raw request body, as bytes."""
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for entry in headers["webhook-signature"].split():
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False

A received data.changed request shows the headers first and then the body, exactly as signed.

request
POST /xfinlink HTTP/1.1
Host: example.com
content-type: application/json
user-agent: xfinlink-webhooks/1
webhook-id: evt_d893e77f73f99b23cd56df29
webhook-timestamp: 1790805811
webhook-signature: v1,/74HUS4RHr0at43k+Xr0Esgb/7jDdu2fQT4mlLQYNA4=

{"id":"evt_d893e77f73f99b23cd56df29","type":"data.changed","created":"2026-09-30T22:03:31Z","run_id":"prices_patch-20260930-214512","datasets":[{"dataset":"price_adjustments","added":1,"changed":0,"removed":0,"rebuild":0},{"dataset":"prices","added":1,"changed":1,"removed":0,"rebuild":0}],"cursor_from":"MTc5MDgwNTgxMTI1MHw0ODIwOQ==","cursor_to":"MTc5MDgwNTgxMTI1MHw0ODIxMg==","changes_url":"https://api.xfinlink.com/v1/changes"}

The event carries no data, and the feed answers only requests that carry your API key. Each unverified request can cost at most one extra feed call, so signature checks can wait until a first integration works.

Deliveries come from Cloudflare's shared address ranges, which other Cloudflare customers use too. Rely on the signature rather than an address allowlist.

Delivery

  • Each event is a POST over https, with a JSON body.
  • An event is sent within about a minute of its records appearing in the change feed.
  • A 2xx answer within 10 seconds counts as delivered. Any other status, a redirect, a timeout or a failed connection counts as a failure. Redirects are not followed, so point the webhook at the final address.
  • Answer with 2xx first, then read the feed.
  • Events can arrive more than once and out of order. The feed, read from your saved cursor, returns every record once and in order.

Retries

A failed delivery is retried up to 9 times. The waits before the retries are about 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours and 24 hours. After the tenth failed attempt, the delivery stops and the delivery log shows No retries left.

Turning endpoints off and on

  • An endpoint whose server answers 410 is turned off at once, and the dashboard shows it as Disabled.
  • An endpoint whose deliveries have all failed for three days is turned off, and the account's email address receives a notice.
  • Pause on the dashboard stops all sending to an endpoint until you press Resume.
  • Events created while an endpoint is off or paused are not sent to it later. After you turn it back on, read the feed from your saved cursor to catch up.
  • Resume turns a Disabled endpoint back on and restarts its three-day count.
  • When the account moves to a plan without webhooks, its endpoints stay listed and receive nothing.

Rotate the secret

Rotate secret on the dashboard creates a new secret and shows it once. The previous secret keeps signing for 24 hours, and during that time every request carries two signatures, one per secret. A request is genuine when any v1 signature matches a secret you hold. Move your server to the new secret within the 24 hours.

Delivery log

Deliveries, under each endpoint on the dashboard, lists its 50 most recent deliveries, newest first, with the time, event type, attempt number, status and HTTP status. The statuses are Pending, with the time of the next attempt, Delivered, Failed and No retries left. Resend sends a Failed or No retries left delivery again, with the same event id and a new timestamp and signature. Test events appear in the log too. Deliveries stay in the log for 30 days.

Limits

  • Max allows up to 5 endpoints per account.
  • Redistribution allows up to 10 endpoints per account.
  • Free and Pro have no webhooks. The dashboard points to the plans that include them.

Every endpoint receives every data.changed event.

URL requirements

  • The URL starts with https://. Plain http is refused.
  • The host is a hostname, not an IP address.
  • Every IPv4 and IPv6 address the hostname resolves to is public. Private, loopback, link-local, carrier-grade NAT and multicast addresses are refused.
  • localhost, names ending in .local, .internal or .localhost, and hostnames under xfinlink.com or workers.dev are refused.
  • The hostname resolves in public DNS.
  • The URL is at most 2,048 characters.

The URL is checked when you add it and again before every delivery attempt. A URL that fails the check at delivery time counts as a failed delivery.

Was this page helpful?