Skip to content
Docs

Type to search. Arrow keys to move, Enter to open, Escape to close.

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

View as Markdown

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

  1. 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.
  2. Keep the key on your server. A read key reaches the read tools and none of the tools that change records.
  3. Call GET /api/keys/me with 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 dashboardTool
The last 30 days at a glanceget_client_snapshot
Where each lead came fromlist_leads
Which content brought the moneyget_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.

This page is written by hand. Something missing or unclear? Get help.