Skip to content
FileSignal

API reference · v1

FileSignal API and webhooks.

Pull signals into your own systems with a REST API, or have them pushed to your CRM with signed webhooks. JSON everywhere, plan scope enforced on the server.

Overview

Base URL: https://filesignal.ca/api/v1. Every endpoint returns JSON. Dates are YYYY-MM-DD; timestamps are ISO 8601 in UTC. List endpoints use cursor pagination: pass the next_cursor from one response as cursor in the next, until it is null.

API access is included in the API plan and data licences. Results are always limited to your plan's scope — municipalities, category groups and history window — and residential records are never returned.

Authentication

Create a key in /app/integrations. Keys look like fs_live_…, are shown once at creation and stored only as a hash — if you lose one, revoke it and create another. Send it as a Bearer token:

http
Authorization: Bearer fs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keep keys server-side. Never put them in browser code or a public repository.

List signals

GET/signals

Returns signals newest first, filtered by any combination of these query parameters:

GET /signals query parameters
NameTypeDescription
municipalitystringMunicipality slug, e.g. toronto. Comma-separated for several.
category_groupstringCategory group slug, e.g. food-hospitality. Comma-separated allowed.
categorystringCategory slug, e.g. restaurants-bars. Comma-separated allowed.
signal_typestringnew | renewal | change | closed. Comma-separated allowed, e.g. new,change.
fromdateYYYY-MM-DD. Earliest detected date (inclusive). Clamped to your plan's history window.
todateYYYY-MM-DD. Latest detected date (inclusive).
has_phonebooleantrue to return only signals whose public record includes a phone number.
qstringFree-text search over business and legal name.
limitinteger1–200. Default 50.
cursorstringOpaque cursor from a previous response's next_cursor.

Response: { data: Signal[], next_cursor: string | null }

curl
curl -s "https://filesignal.ca/api/v1/signals?municipality=toronto&category_group=food-hospitality&signal_type=new&has_phone=true&limit=50" \
  -H "Authorization: Bearer $FILESIGNAL_API_KEY"
200 OK · example (fictional values)
{
  "data": [
    {
      "id": "6f1c2a7e-0d4b-4a39-9a51-2c8e5b1f0e77",
      "signal_type": "new",
      "detected_at": "2026-10-28T11:16:04Z",
      "effective_date": "2026-10-27",
      "business_name": "Example Noodle House",
      "legal_name": "Example Noodle House Inc.",
      "phone": "(416) 555-0100",
      "address": "123 Example St W",
      "city": "Toronto",
      "postal_code": "M5V 0A1",
      "province": "ON",
      "municipality": "Toronto",
      "category": "Restaurants & bars",
      "category_group": "Food & hospitality",
      "confidence": 0.95,
      "source": "Municipal Licensing & Standards — Business Licences and Permits",
      "source_record_id": "B71-0000000",
      "source_url": "https://open.toronto.ca/dataset/municipal-licensing-and-standards-business-licences-and-permits/"
    }
  ],
  "next_cursor": "eyJkIjoiMjAyNi0xMC0yOFQxMToxNjowNFoiLCJpIjoiNmYxYy4uLiJ9"
}
JavaScript (fetch) · paginate and back off on 429
const BASE = "https://filesignal.ca/api/v1";

async function* allSignals(params) {
  let cursor = null;
  do {
    const qs = new URLSearchParams({ ...params, limit: "200", ...(cursor ? { cursor } : {}) });
    const res = await fetch(`${BASE}/signals?${qs}`, {
      headers: { Authorization: `Bearer ${process.env.FILESIGNAL_API_KEY}` },
    });
    if (res.status === 429) {
      const wait = Number(res.headers.get("Retry-After") ?? 60);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (!res.ok) {
      const { error } = await res.json();
      throw new Error(`${res.status} ${error.code}: ${error.message}`);
    }
    const body = await res.json();
    yield* body.data;
    cursor = body.next_cursor;
  } while (cursor);
}

for await (const s of allSignals({ signal_type: "new", from: "2026-10-01" })) {
  console.log(s.business_name, s.category, s.city);
}

Retrieve a signal

GET/signals/{id}

Returns one signal plus changed_fields (for change signals) and field_provenance: for each delivered field, the publisher's source column, the source record id and when we fetched it. Returns 404 if the id doesn't exist or is outside your plan's scope.

curl
curl -s "https://filesignal.ca/api/v1/signals/6f1c2a7e-0d4b-4a39-9a51-2c8e5b1f0e77" \
  -H "Authorization: Bearer $FILESIGNAL_API_KEY"
200 OK · example
{
  "data": {
    "id": "6f1c2a7e-0d4b-4a39-9a51-2c8e5b1f0e77",
    "signal_type": "new",
    "business_name": "Example Noodle House",
    "...": "all Signal fields",
    "changed_fields": [],
    "field_provenance": {
      "business_name": { "source_field": "Operating Name", "source_record_id": "B71-0000000", "fetched_at": "2026-10-28T11:15:41Z" },
      "phone": { "source_field": "Business Phone", "source_record_id": "B71-0000000", "fetched_at": "2026-10-28T11:15:41Z" }
    }
  }
}

List categories

GET/categories

All categories and their groups. Use the slugs as category and category_group filters.

200 OK · excerpt
{
  "data": [
    { "slug": "restaurants-bars", "name": "Restaurants & bars", "group_slug": "food-hospitality", "group_name": "Food & hospitality" },
    { "slug": "personal-services", "name": "Salons, spas & personal care", "group_slug": "personal-services", "group_name": "Personal services" }
  ]
}

List municipalities

GET/municipalities

Every municipality we track, with its coverage status: live, building or planned. Only live municipalities return signals. See coverage for details.

200 OK · excerpt
{
  "data": [
    { "slug": "toronto", "name": "Toronto", "region": "Toronto", "status": "live" },
    { "slug": "mississauga", "name": "Mississauga", "region": "Peel", "status": "building" },
    { "slug": "markham", "name": "Markham", "region": "York", "status": "planned" }
  ]
}

OpenAPI spec

GET/openapi.json

A machine-readable OpenAPI 3 description of this API at https://filesignal.ca/api/v1/openapi.json. Import it into Postman, Insomnia or a client generator.

The Signal object

Signals are flat objects so they map cleanly onto CRM fields and CSV columns. Filters take slugs (from /categories and /municipalities); responses carry the human-readable names.

Signal fields
FieldTypeDescription
iduuidStable signal id.
signal_typestringnew, renewal, change or closed.
detected_attimestampWhen FileSignal first saw the change (ISO 8601, UTC).
effective_datedate | nullDate on the public record, e.g. the licence issue date.
business_namestringOperating name, normalised.
legal_namestring | nullRegistrant / legal name where published (businesses only).
phonestring | nullOnly when the public record includes one and your plan includes phone numbers.
addressstring | nullStreet address line.
citystring | nullCity from the address (may differ from the licensing municipality).
postal_codestring | nullValidated Canadian postal code, e.g. M5V 0A1.
provincestringTwo-letter province code.
municipalitystring | nullLicensing municipality name, e.g. Toronto.
categorystring | nullCategory name, e.g. Restaurants & bars.
category_groupstring | nullCategory group name, e.g. Food & hospitality.
confidencenumber0–1. How certain the detection rule is (see How it works).
sourcestringName of the publisher dataset the record came from.
source_record_idstringThe publisher's record id (e.g. licence number).
source_urlstring | nullLink to the publisher's dataset page.

Errors and rate limits

Errors return a JSON body { error: { code, message } } with one of these statuses:

Error status codes
StatusCodeMeaning
400invalid_requestA parameter is invalid (e.g. limit out of range, malformed date or cursor). The message says which.
401unauthorizedMissing, malformed or revoked API key.
403plan_requiredYour plan doesn't include API access, or you asked for a municipality or category outside your plan's scope.
404not_foundThe signal doesn't exist or isn't visible to your plan.
429rate_limitedRate limit exceeded. Wait for the number of seconds in Retry-After.

Rate limits apply per key, per minute, and depend on your plan (the API plan allows 120 requests a minute). Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 also carries Retry-After.

429 example
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
Content-Type: application/json

{ "error": { "code": "rate_limited", "message": "Rate limit exceeded (120 requests per minute). Retry after 60 seconds." } }

Webhooks

Add an endpoint in /app/integrations (Pro plan and up). After each collection run, new signals matching the webhook's filter are POSTed as JSON. You can send a test event from the same page.

  • event is signals.created or test; count is the number of signals in data.
  • Headers: X-FileSignal-Event and X-FileSignal-Signature: t=<unix>,v1=<hex>.
  • Respond with any 2xx within 10 seconds. Anything else, or a timeout, is retried: 3 attempts in total, at 0 s, 2 s and 6 s.
  • A retried delivery can arrive twice. Upsert on the signal id.
  • Endpoints must be public HTTPS URLs. Redirects are not followed. If all three attempts fail, the account owner gets an email.
Example delivery
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-FileSignal-Event: signals.created
X-FileSignal-Signature: t=1761650165,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{
  "event": "signals.created",
  "sent_at": "2026-10-28T11:16:05Z",
  "count": 1,
  "data": [ { "id": "6f1c2a7e-…", "signal_type": "new", "business_name": "Example Noodle House", "...": "all Signal fields" } ]
}

Verifying signatures

v1 is the hex HMAC-SHA256 of `${t}.${rawBody}` using your webhook secret (whsec_…, shown once when you create the webhook). Recompute it over the raw request body, compare in constant time, and reject timestamps more than five minutes from your clock to prevent replays.

Node.js · Express
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const SECRET = process.env.FILESIGNAL_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 5 * 60;

export function verifySignature(rawBody, header, secret = SECRET, now = Math.floor(Date.now() / 1000)) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || !parts.v1 || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();

// Use the raw body: re-serialising parsed JSON changes the bytes and breaks the signature.
app.post("/webhooks/filesignal", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifySignature(raw, req.get("X-FileSignal-Signature"))) return res.status(400).send("bad signature");
  const payload = JSON.parse(raw);
  if (payload.event === "signals.created") {
    for (const s of payload.data) {
      // Upsert on s.id — deliveries can repeat after a retry.
    }
  }
  res.sendStatus(204); // respond 2xx within 10 seconds
});

GoHighLevel quick start

  1. In GoHighLevel, open Automation → Workflows, create a workflow and add the Inbound Webhook trigger. Copy the URL it gives you.
  2. In FileSignal, go to /app/integrations, add a webhook with that URL and choose the filter (for example, new food businesses in Toronto with a phone). Click Send test so GoHighLevel receives a sample payload.
  3. Back in the trigger, select the test request as the mapping reference. Add a Create/Update Contact action and map data[0].business_name → Company name, data[0].phone → Phone, data[0].address → Address, data[0].city → City and data[0].category → a custom field or tag.
  4. Add a tag such as filesignal-new and route the contact into your pipeline. Deliveries with several signals send one data array — if you expect more than one per run, set the webhook filter narrowly or loop over the array in a custom code step.

GoHighLevel doesn't verify our HMAC signature on inbound webhooks. Keep the webhook URL private, and if you need verification, put a small relay (like the Express example above) in front of it.

Questions about the API? Contact us. Webhook and API usage is subject to the terms and acceptable use policy.