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:
Authorization: Bearer fs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKeep 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:
| Name | Type | Description |
|---|---|---|
| municipality | string | Municipality slug, e.g. toronto. Comma-separated for several. |
| category_group | string | Category group slug, e.g. food-hospitality. Comma-separated allowed. |
| category | string | Category slug, e.g. restaurants-bars. Comma-separated allowed. |
| signal_type | string | new | renewal | change | closed. Comma-separated allowed, e.g. new,change. |
| from | date | YYYY-MM-DD. Earliest detected date (inclusive). Clamped to your plan's history window. |
| to | date | YYYY-MM-DD. Latest detected date (inclusive). |
| has_phone | boolean | true to return only signals whose public record includes a phone number. |
| q | string | Free-text search over business and legal name. |
| limit | integer | 1–200. Default 50. |
| cursor | string | Opaque cursor from a previous response's next_cursor. |
Response: { data: Signal[], next_cursor: string | null }
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"{
"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"
}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 -s "https://filesignal.ca/api/v1/signals/6f1c2a7e-0d4b-4a39-9a51-2c8e5b1f0e77" \
-H "Authorization: Bearer $FILESIGNAL_API_KEY"{
"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.
{
"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.
{
"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.
| Field | Type | Description |
|---|---|---|
| id | uuid | Stable signal id. |
| signal_type | string | new, renewal, change or closed. |
| detected_at | timestamp | When FileSignal first saw the change (ISO 8601, UTC). |
| effective_date | date | null | Date on the public record, e.g. the licence issue date. |
| business_name | string | Operating name, normalised. |
| legal_name | string | null | Registrant / legal name where published (businesses only). |
| phone | string | null | Only when the public record includes one and your plan includes phone numbers. |
| address | string | null | Street address line. |
| city | string | null | City from the address (may differ from the licensing municipality). |
| postal_code | string | null | Validated Canadian postal code, e.g. M5V 0A1. |
| province | string | Two-letter province code. |
| municipality | string | null | Licensing municipality name, e.g. Toronto. |
| category | string | null | Category name, e.g. Restaurants & bars. |
| category_group | string | null | Category group name, e.g. Food & hospitality. |
| confidence | number | 0–1. How certain the detection rule is (see How it works). |
| source | string | Name of the publisher dataset the record came from. |
| source_record_id | string | The publisher's record id (e.g. licence number). |
| source_url | string | null | Link to the publisher's dataset page. |
Errors and rate limits
Errors return a JSON body { error: { code, message } } with one of these statuses:
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | A parameter is invalid (e.g. limit out of range, malformed date or cursor). The message says which. |
| 401 | unauthorized | Missing, malformed or revoked API key. |
| 403 | plan_required | Your plan doesn't include API access, or you asked for a municipality or category outside your plan's scope. |
| 404 | not_found | The signal doesn't exist or isn't visible to your plan. |
| 429 | rate_limited | Rate 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.
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.
eventissignals.createdortest;countis the number of signals indata.- Headers:
X-FileSignal-EventandX-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.
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.
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
- In GoHighLevel, open Automation → Workflows, create a workflow and add the Inbound Webhook trigger. Copy the URL it gives you.
- 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. - 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 anddata[0].category→ a custom field or tag. - Add a tag such as
filesignal-newand route the contact into your pipeline. Deliveries with several signals send onedataarray — 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.