Monitoring API examples

This guide builds a read-only scheduled health check from the documented Control API. It detects failed Workflow runs, Instant Trigger errors or fallback, active channel alerts, and result-window overflow without retrieving payload previews.

Required configuration

Run the example with Node.js 18 or later. It uses only built-in fetch and node:crypto.

| Variable | Required | Purpose | | --- | --- | --- | | FASTHOOK_API_KEY | Yes | Project API key for the monitored team. | | FASTHOOK_TEAM_ID | Yes | Team sent as x-team-id. Must match the project key. | | FASTHOOK_API_BASE | No | Defaults to https://api.fasthook.io. | | FASTHOOK_WINDOW_FROM | No | Activity start, defaults to now-15m. | | FASTHOOK_ALERT_WEBHOOK_URL | No | Optional Slack-compatible webhook that receives a summary without payloads or error messages. |

Store these values in the scheduler’s secret manager. Do not place the API key or alert webhook in the script, command line, repository, logs, or report output.

Dependency-free health check

Save this as fasthook-health-check.mjs:

JS
import { createHash } from "node:crypto";

const API_BASE = (process.env.FASTHOOK_API_BASE || "https://api.fasthook.io").replace(/\/$/, "");
const API_KEY = process.env.FASTHOOK_API_KEY;
const TEAM_ID = process.env.FASTHOOK_TEAM_ID;
const WINDOW_FROM = process.env.FASTHOOK_WINDOW_FROM || "now-15m";
const ALERT_WEBHOOK_URL = process.env.FASTHOOK_ALERT_WEBHOOK_URL || "";

if (!API_KEY || !TEAM_ID) {
  console.error("FASTHOOK_API_KEY and FASTHOOK_TEAM_ID are required");
  process.exit(3);
}

async function fasthook(path) {
  const response = await fetch(`${API_BASE}${path}`, {
    headers: {
      authorization: `Bearer ${API_KEY}`,
      "x-team-id": TEAM_ID,
      accept: "application/json"
    },
    signal: AbortSignal.timeout(20_000)
  });
  if (!response.ok) throw new Error(`FastHook ${path} returned HTTP ${response.status}`);
  return response.json();
}

function finding(severity, kind, id, detail = {}) {
  return { severity, kind, id, ...detail };
}

async function main() {
  const activityQuery = new URLSearchParams({
    status: "failed",
    from: WINDOW_FROM,
    to: "now",
    limit: "100"
  });
  const [channelResponse, alertResponse, activityResponse] = await Promise.all([
    fasthook("/v1/sources/channels"),
    fasthook("/v1/sources/channel-alerts?status=active&limit=100"),
    fasthook(`/v1/workflow-activity?${activityQuery}`)
  ]);

  const channels = Array.isArray(channelResponse.models) ? channelResponse.models : [];
  const alerts = Array.isArray(alertResponse.models) ? alertResponse.models : [];
  const runs = Array.isArray(activityResponse.models) ? activityResponse.models : [];
  const findings = [];

  for (const source of channels) {
    const status = source?.channel?.status;
    if (status === "error") {
      findings.push(finding("critical", "trigger_channel_error", source.source_id, {
        provider_key: source.provider_key || null
      }));
    } else if (status === "fallback") {
      findings.push(finding("warning", "trigger_polling_fallback", source.source_id, {
        provider_key: source.provider_key || null
      }));
    } else if (status === "expiring" || status === "connecting") {
      findings.push(finding("warning", `trigger_channel_${status}`, source.source_id, {
        provider_key: source.provider_key || null
      }));
    }
  }

  for (const alert of alerts) {
    findings.push(finding("critical", "active_trigger_alert", alert.id, {
      source_id: alert.source_id || null,
      code: alert.code || null,
      occurrences: Number(alert.occurrences || 0)
    }));
  }

  for (const run of runs) {
    findings.push(finding("critical", "failed_workflow_run", run.id, {
      workflow_id: run.workflow_id || null,
      source_id: run.source?.id || null,
      request_id: run.request_id || null,
      error_code: run.error_code || null
    }));
  }

  const activityTotal = Number(activityResponse.total || 0);
  if (activityTotal > runs.length) {
    findings.push(finding("critical", "activity_window_overflow", "workflow-activity", {
      returned: runs.length,
      total: activityTotal
    }));
  }
  if (channels.length >= 250) {
    findings.push(finding("warning", "channel_inventory_may_be_truncated", "sources-channels", {
      returned: channels.length
    }));
  }

  findings.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
  const fingerprint = createHash("sha256")
    .update(JSON.stringify(findings.map(({ severity, kind, id }) => ({ severity, kind, id }))))
    .digest("hex").slice(0, 16);
  const critical = findings.filter((item) => item.severity === "critical").length;
  const warning = findings.filter((item) => item.severity === "warning").length;
  const report = {
    schema_version: 1,
    generated_at: new Date().toISOString(),
    team_id: TEAM_ID,
    window: { from: WINDOW_FROM, to: "now" },
    status: critical ? "critical" : warning ? "warning" : "healthy",
    fingerprint,
    counts: {
      critical,
      warning,
      failed_runs: runs.length,
      active_trigger_alerts: alerts.length,
      instant_sources_returned: channels.length
    },
    findings
  };

  console.log(JSON.stringify(report));
  if (ALERT_WEBHOOK_URL && findings.length) {
    const response = await fetch(ALERT_WEBHOOK_URL, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        text: `FastHook health ${report.status}: ${critical} critical, ${warning} warning; fingerprint ${fingerprint}`
      }),
      signal: AbortSignal.timeout(10_000)
    });
    if (!response.ok) throw new Error(`Alert webhook returned HTTP ${response.status}`);
  }
  process.exitCode = critical ? 2 : warning ? 1 : 0;
}

main().catch((error) => {
  console.error(JSON.stringify({
    schema_version: 1,
    generated_at: new Date().toISOString(),
    status: "monitor_error",
    error: error instanceof Error ? error.message : "unknown_monitor_error"
  }));
  process.exitCode = 3;
});

The script excludes provider error messages, payloads, Step outputs, account metadata, and credentials from the optional escalation message.

| Exit code | Meaning | | --- | --- | | 0 | No finding in the checked window. | | 1 | Warning: connecting, expiring, fallback, or possible channel-inventory truncation. | | 2 | Critical: failed run, error channel, active Trigger alert, or Activity overflow. | | 3 | The monitor itself failed or was misconfigured. |

Treat monitor_error as actionable. A monitor that cannot authenticate or query FastHook must not report the system as healthy.

Schedule it safely

Run the script every 5–15 minutes from a scheduler that injects secrets safely, prevents overlap, enforces a timeout, captures one JSON report per execution, and alerts on exit codes 2 and 3.

Use an Activity window slightly wider than the schedule interval so a short scheduler delay does not create a gap. That overlap can repeat the same finding. Deduplicate notifications by fingerprint in the alert receiver or apply its cooldown policy.

This example is read-only. Do not automatically retry a channel, change delivery mode, rotate credentials, pause Workflows, or replay events from a basic health-check finding.

Focused failure-window queries

Failed runs for one Source during the last hour:

Shell
curl --fail-with-body --silent --show-error \
  --header "Authorization: Bearer $FASTHOOK_API_KEY" \
  --header "x-team-id: $FASTHOOK_TEAM_ID" \
  "$FASTHOOK_API_BASE/v1/workflow-activity?status=failed&source_id=src_xxx&from=now-1h&to=now&limit=100"

Active channel alerts:

Shell
curl --fail-with-body --silent --show-error \
  --header "Authorization: Bearer $FASTHOOK_API_KEY" \
  --header "x-team-id: $FASTHOOK_TEAM_ID" \
  "$FASTHOOK_API_BASE/v1/sources/channel-alerts?status=active&limit=100"

Instant Sources currently in fallback:

Shell
curl --fail-with-body --silent --show-error \
  --header "Authorization: Bearer $FASTHOOK_API_KEY" \
  --header "x-team-id: $FASTHOOK_TEAM_ID" \
  "$FASTHOOK_API_BASE/v1/sources/channels?status=fallback"

Always compare workflow-activity.total with count. If total is larger, split the time range; do not silently report only the first 100 rows. The channel inventory endpoint returns at most 250 rows and has no total, so a response of exactly 250 is potentially incomplete.

Escalation payload contract

Forward identifiers and counts rather than raw provider errors or customer data:

JSON
{
  "schema_version": 1,
  "event_type": "fasthook.health.findings",
  "generated_at": "2026-08-27T12:00:00.000Z",
  "team_id": "tm_example",
  "window": { "from": "now-15m", "to": "now" },
  "status": "critical",
  "fingerprint": "da73f11d00ce4b5b",
  "counts": { "critical": 2, "warning": 1 },
  "references": {
    "workflow_ids": ["wfl_example"],
    "source_ids": ["src_example"],
    "run_ids": ["wfr_example"],
    "request_ids": ["req_example"]
  }
}

The receiver can enrich identifiers through authenticated API calls during triage. Continue with Reconciliation reports and the incident response runbook.