Guides
Build your own dashboard on SIGNL
Read SIGNL from a dashboard you built yourself: one read key, three tools and a drop-in TypeScript connector that never throws.
Updated 19 October 2026
What this covers
If you run a dashboard you built yourself, it can read SIGNL with one API key and three tools. Below are the three calls, the limits to plan for, and a file to drop into your project.
Make a read key
- Open Integrations in the client workspace and create an API key with only the Read scope. Organisation Settings holds the same list for a key that reaches every workspace.
- Keep the key on your server. A read key reaches the read tools and none of the tools that change records.
- Call
GET /api/keys/mewith it once: the answer is the key's own scopes and the workspace it reads.
Ask for what the dashboard needs
Every tool is a REST endpoint. Send POST /api/v1/tools/<name> with Authorization: Bearer <key> and the tool's input as a JSON body, and add workspace to the body when the key is organisation-wide.
| On your dashboard | Tool |
|---|---|
| The last 30 days at a glance | get_client_snapshot |
| Where each lead came from | list_leads |
| Which content brought the money | get_content_performance |
The snapshot answer holds new leads, calls logged, showed and won, and net revenue, all for the last 30 days.
The leads answer holds each lead with its first touch: source, medium and campaign.
The content answer holds one row per content item with clicks on its link, and the forms, calls booked and revenue of people whose first tracked link it was.
Add days to the content call to count one window: every item then also carries shows, wins and cash for that window. Clicks and revenue in a window come from the dated click ledger, which starts on 4 October 2026.
Limits to plan for
A key makes up to 60 requests a minute to the tool endpoints. Read once on a schedule, store what you read, and render from your own store.
A 503 is a fault on SIGNL's side and comes with Retry-After; wait that long and try again. A 429 means slow down.
Lists come in pages: send offset with the nextOffset from the last page until hasMore is false.
The drop-in connector
Copy the file below into your project as signl-connector.ts. It has no dependencies. Each call returns a status card and the parsed data, or null, and never throws, so one card on your page can say what went wrong while the rest still renders.
/**
* SIGNL connector for a dashboard you build yourself.
*
* Drop this one file into your project. It reads three SIGNL tools over the REST API with a read key. Every call answers
* with a status card and the parsed data, or null. It never throws on an outage, a refused key or a reply it does not
* understand, so one card on your page can say what went wrong while the rest still renders.
*
* Each request is POST <your SIGNL address>/api/v1/tools/<tool name>, with "Authorization: Bearer <key>" and the tool's
* input as a JSON body. An organisation-wide key also sends "workspace". The guide: /docs/guides/build-your-own.
*
* Three rules this file keeps, and your dashboard should keep too:
* 1. Store what you read. contentRowsForStorage gives rows ready to save with the time you read them, so your history
* does not depend on a live call while the page renders.
* 2. Name each number for what it is. A content item's revenue is first touch, and its stored column says so.
* 3. Never spread revenue. An item's revenue is what SIGNL credited to it; nothing here shares a total out by views or
* by matching titles.
*
* No imports and no dependencies. Runs wherever fetch exists (Node 18 or later, edge runtimes, Deno, Bun).
*/
export interface SignlConfig {
/** Your SIGNL address, for example https://app.example.com. */
readonly baseUrl: string
/** An API key with the read scope. Keep it on your server; never send it to a browser. */
readonly apiKey: string
/** Only for an organisation-wide key: the slug or id of the workspace to read. */
readonly workspace?: string
/** How long to wait for an answer. Default 15 seconds. */
readonly timeoutMs?: number
/** For tests, or a runtime with its own fetch. */
readonly fetchImpl?: typeof fetch
}
export type SignlErrorReason = 'auth' | 'scope' | 'rate_limited' | 'unavailable' | 'bad_request' | 'bad_response'
export type SignlStatus =
| { readonly state: 'ok'; readonly checkedAt: string }
| { readonly state: 'not_configured'; readonly checkedAt: string; readonly message: string }
| {
readonly state: 'error'
readonly checkedAt: string
readonly reason: SignlErrorReason
readonly message: string
/** Seconds to wait before trying again, when SIGNL said. */
readonly retryAfterSeconds: number | null
}
export interface SignlResult<T> {
readonly status: SignlStatus
readonly data: T | null
}
/** The 30-day figures from get_client_snapshot. */
export interface SnapshotSummary {
readonly workspaceName: string
readonly generatedAt: string
readonly rangeDays: number
readonly newLeads: number
readonly callsLogged: number
readonly callsShowed: number
readonly callsWon: number
/** Minor units (pence for GBP). */
readonly netRevenuePence: number
}
export interface LeadRow {
readonly id: string
readonly name: string | null
readonly email: string | null
readonly createdAt: string
readonly totalRevenuePence: number
readonly firstTouch: { readonly source: string | null; readonly medium: string | null; readonly campaign: string | null }
}
export interface LeadsPage {
readonly leads: readonly LeadRow[]
readonly hasMore: boolean
readonly nextOffset: number | null
}
/** One content item from get_content_performance. Counts are first touch: people whose first tracked link was this item. */
export interface ContentRow {
readonly id: string
readonly name: string
/** The key of the source the item sits under: youtube, instagram, email, or one of your own. */
readonly source: string
readonly sourceName: string
/** What the item is: video, post, broadcast, and so on. */
readonly kind: string
/** The platform's own id when SIGNL synced the item (a YouTube video id), else null. */
readonly externalId: string | null
/** How many live tracked links sit under the item; its clicks and revenue are summed over them. */
readonly links: number
readonly clicks: number
readonly forms: number
readonly bookings: number
readonly revenuePence: number
/** Calls that showed, calls won and ledger cash by currency; null when SIGNL did not send them. */
readonly shows: number | null
readonly wins: number | null
readonly cash: Readonly<Record<string, number>> | null
}
export interface ContentPage {
readonly basis: string
readonly items: readonly ContentRow[]
readonly hasMore: boolean
readonly nextOffset: number | null
}
export interface ContentQuery {
readonly sort?: 'newest' | 'revenue' | 'bookings' | 'forms' | 'clicks' | 'name'
readonly source?: string
/** Count only the last N days (1 to 365). Leave out for all time. */
readonly days?: number
readonly limit?: number
readonly offset?: number
}
// Parsing: every field is checked; a reply in another shape is refused, never guessed at.
type Obj = Record<string, unknown>
const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
const str = (v: unknown): string | null => (typeof v === 'string' ? v : null)
const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
const bool = (v: unknown): boolean | null => (typeof v === 'boolean' ? v : null)
function moneyByCurrency(v: unknown): Record<string, number> | null {
if (!isObj(v)) return null
const out: Record<string, number> = {}
for (const [currency, amount] of Object.entries(v)) {
const n = num(amount)
if (n === null || !/^[A-Z]{3}$/.test(currency)) return null
out[currency] = n
}
return out
}
export function parseSnapshot(json: unknown): SnapshotSummary | null {
if (!isObj(json) || !isObj(json.client) || !isObj(json.kpis)) return null
const workspaceName = str(json.client.name)
const generatedAt = str(json.generatedAt)
const rangeDays = num(json.rangeDays)
const newLeads = num(json.kpis.newLeads30d)
const callsLogged = num(json.kpis.callsLogged30d)
const callsShowed = num(json.kpis.callsShown30d)
const callsWon = num(json.kpis.callsWon30d)
const netRevenuePence = num(json.kpis.netRevenue30dPence)
if (
workspaceName === null || generatedAt === null || rangeDays === null || newLeads === null || callsLogged === null
|| callsShowed === null || callsWon === null || netRevenuePence === null
) return null
return { workspaceName, generatedAt, rangeDays, newLeads, callsLogged, callsShowed, callsWon, netRevenuePence }
}
function parseLead(v: unknown): LeadRow | null {
if (!isObj(v) || !isObj(v.firstTouch)) return null
const id = str(v.id)
const createdAt = str(v.createdAt)
const totalRevenuePence = num(v.totalRevenuePence)
if (id === null || createdAt === null || totalRevenuePence === null) return null
return {
id,
name: str(v.name),
email: str(v.email),
createdAt,
totalRevenuePence,
firstTouch: { source: str(v.firstTouch.source), medium: str(v.firstTouch.medium), campaign: str(v.firstTouch.campaign) },
}
}
export function parseLeadsPage(json: unknown): LeadsPage | null {
if (!isObj(json) || !Array.isArray(json.leads)) return null
const hasMore = bool(json.hasMore)
if (hasMore === null) return null
const parsed = json.leads.map(parseLead)
const leads = parsed.filter((lead): lead is LeadRow => lead !== null)
if (leads.length !== parsed.length) return null
return { leads, hasMore, nextOffset: num(json.nextOffset) }
}
function parseContentRow(v: unknown): ContentRow | null {
if (!isObj(v)) return null
const id = str(v.id)
const name = str(v.name)
const source = str(v.source)
const sourceName = str(v.sourceName)
const kind = str(v.kind)
const links = num(v.links)
const clicks = num(v.clicks)
const forms = num(v.forms)
const bookings = num(v.bookings)
const revenuePence = num(v.revenuePence)
if (
id === null || name === null || source === null || sourceName === null || kind === null || links === null
|| clicks === null || forms === null || bookings === null || revenuePence === null
) return null
return {
id, name, source, sourceName, kind, externalId: str(v.externalId), links, clicks, forms, bookings, revenuePence,
shows: num(v.shows), wins: num(v.wins), cash: moneyByCurrency(v.cash),
}
}
export function parseContentPage(json: unknown): ContentPage | null {
if (!isObj(json) || !Array.isArray(json.items)) return null
const basis = str(json.basis)
const hasMore = bool(json.hasMore)
if (basis === null || hasMore === null) return null
const parsed = json.items.map(parseContentRow)
const items = parsed.filter((item): item is ContentRow => item !== null)
if (items.length !== parsed.length) return null
return { basis, items, hasMore, nextOffset: num(json.nextOffset) }
}
// Requests.
const DEFAULT_TIMEOUT_MS = 15_000
const FALLBACK_MESSAGE: Readonly<Record<SignlErrorReason, string>> = {
auth: 'SIGNL refused the API key. Check it has not been revoked.',
scope: 'This key cannot read that. It needs the read scope, and an organisation-wide key needs a workspace.',
rate_limited: 'Too many requests to SIGNL. Wait, then try again.',
unavailable: 'SIGNL is not answering right now.',
bad_request: 'SIGNL did not accept the request.',
bad_response: 'SIGNL answered in a shape this connector does not understand.',
}
function reasonFor(httpStatus: number): SignlErrorReason {
if (httpStatus === 401) return 'auth'
if (httpStatus === 403) return 'scope'
if (httpStatus === 429) return 'rate_limited'
if (httpStatus >= 500) return 'unavailable'
return 'bad_request'
}
function retryAfterSeconds(header: string | null): number | null {
const value = header?.trim() ?? ''
return /^\d+$/.test(value) ? Number(value) : null
}
async function callTool(
config: SignlConfig,
tool: string,
input: Readonly<Record<string, unknown>>,
): Promise<{ readonly status: SignlStatus; readonly json: unknown }> {
const checkedAt = new Date().toISOString()
const base = config.baseUrl.trim().replace(/\/+$/, '')
const key = config.apiKey.trim()
if (base === '' || key === '') {
return { status: { state: 'not_configured', checkedAt, message: 'Set your SIGNL address and a read API key.' }, json: null }
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), config.timeoutMs ?? DEFAULT_TIMEOUT_MS)
try {
const response = await (config.fetchImpl ?? fetch)(`${base}/api/v1/tools/${tool}`, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify(config.workspace ? { workspace: config.workspace, ...input } : input),
signal: controller.signal,
cache: 'no-store',
})
const json: unknown = await response.json().catch(() => null)
if (response.ok) return { status: { state: 'ok', checkedAt }, json }
const reason = reasonFor(response.status)
const said = isObj(json) ? str(json.error) : null
return {
status: {
state: 'error', checkedAt, reason, message: said ?? FALLBACK_MESSAGE[reason],
retryAfterSeconds: retryAfterSeconds(response.headers.get('retry-after')),
},
json: null,
}
} catch (error) {
const timedOut = error instanceof Error && error.name === 'AbortError'
return {
status: {
state: 'error', checkedAt, reason: 'unavailable',
message: timedOut ? 'SIGNL did not answer in time.' : 'SIGNL could not be reached.', retryAfterSeconds: null,
},
json: null,
}
} finally {
clearTimeout(timer)
}
}
async function read<T>(
config: SignlConfig,
tool: string,
input: Readonly<Record<string, unknown>>,
parse: (json: unknown) => T | null,
): Promise<SignlResult<T>> {
const { status, json } = await callTool(config, tool, input)
if (status.state !== 'ok') return { status, data: null }
const data = parse(json)
if (data !== null) return { status, data }
return {
status: {
state: 'error', checkedAt: status.checkedAt, reason: 'bad_response',
message: `${FALLBACK_MESSAGE.bad_response} (${tool})`, retryAfterSeconds: null,
},
data: null,
}
}
/** The last 30 days at a glance. */
export function getSnapshot(config: SignlConfig): Promise<SignlResult<SnapshotSummary>> {
return read(config, 'get_client_snapshot', {}, parseSnapshot)
}
/** One page of leads with their first touch. Send nextOffset back as offset until hasMore is false. */
export function listLeads(config: SignlConfig, page: { readonly offset?: number; readonly limit?: number } = {}): Promise<SignlResult<LeadsPage>> {
return read(config, 'list_leads', { limit: page.limit ?? 100, offset: page.offset ?? 0 }, parseLeadsPage)
}
/** One page of content items, most revenue first unless you choose another order. */
export function getContentPerformance(config: SignlConfig, query: ContentQuery = {}): Promise<SignlResult<ContentPage>> {
const input: Record<string, unknown> = { sort: query.sort ?? 'revenue', limit: query.limit ?? 100, offset: query.offset ?? 0 }
if (query.source !== undefined) input.source = query.source
if (query.days !== undefined) input.days = query.days
return read(config, 'get_content_performance', input, parseContentPage)
}
export interface DashboardRead {
/** The first failure among the calls, else ok. */
readonly status: SignlStatus
readonly snapshot: SnapshotSummary | null
readonly leads: readonly LeadRow[]
/** True when the walk stopped at maxLeadPages with more leads left to read. */
readonly leadsTruncated: boolean
readonly content: ContentPage | null
}
/** Everything a dashboard page needs in one go. A failed call leaves its part null or empty and the rest filled. */
export async function readDashboard(
config: SignlConfig,
options: { readonly maxLeadPages?: number; readonly contentDays?: number } = {},
): Promise<DashboardRead> {
const [snapshot, content] = await Promise.all([
getSnapshot(config),
getContentPerformance(config, options.contentDays === undefined ? {} : { days: options.contentDays }),
])
const leads: LeadRow[] = []
let leadStatus: SignlStatus = snapshot.status
let leadsTruncated = false
let offset = 0
const maxPages = options.maxLeadPages ?? 10
for (let page = 0; page < maxPages; page += 1) {
const result = await listLeads(config, { offset })
leadStatus = result.status
if (result.data === null) break
leads.push(...result.data.leads)
if (!result.data.hasMore || result.data.nextOffset === null) break
offset = result.data.nextOffset
if (page === maxPages - 1) leadsTruncated = true
}
const failed = [snapshot.status, content.status, leadStatus].find(s => s.state !== 'ok')
return { status: failed ?? snapshot.status, snapshot: snapshot.data, leads, leadsTruncated, content: content.data }
}
/** A content item as a row to save. Each column says what it holds. */
export interface StoredContentRow {
readonly read_at: string
readonly item_id: string
readonly source: string
readonly name: string
readonly kind: string
readonly external_id: string | null
readonly link_clicks: number
readonly forms_first_touch: number
readonly calls_booked_first_touch: number
readonly revenue_first_touch_pence: number
}
export function contentRowsForStorage(page: ContentPage, readAt: string): StoredContentRow[] {
return page.items.map(item => ({
read_at: readAt,
item_id: item.id,
source: item.source,
name: item.name,
kind: item.kind,
external_id: item.externalId,
link_clicks: item.clicks,
forms_first_touch: item.forms,
calls_booked_first_touch: item.bookings,
revenue_first_touch_pence: item.revenuePence,
}))
}
Schedule readDashboard(config) on your server and keep its output. Your page should use that saved copy.
Rules the connector keeps
Store what you read. contentRowsForStorage turns a page of content into rows to save with the time you read them.
Name each number for what it is. A content item's revenue is credited by first touch, so its stored column is revenue_first_touch_pence. Link clicks divided by video views is a link click rate; keep that name for it.
Never spread revenue across content. A YouTube video can carry several tracked links of its own, one per description or pinned comment. Give each video its own link instead of sharing one link's total out by views or by matching titles.
Where it stops
Content items are credited by first touch only.
Without days, every content number is all time.
Treat the snapshot as a 30-day summary of one workspace. Past 1,000 leads, calls or payments in the window, do your own counting from the full pages of each.
Read next
- Reference: the MCP and REST tools.
- The overview: Prove.