Skip to main content

Overview

Whether can deliver regime change alerts to your systems via webhooks, enabling automated workflows when market conditions shift.

Alert Events

Whether generates alerts when significant regime changes occur:
  • Regime change: Transition to a new regime quadrant
  • Tightness upshift/downshift: Monetary policy signal crosses threshold
  • Risk appetite upshift/downshift: Market risk sentiment crosses threshold

Cooldown Logic

Alerts respect a 24-hour cooldown unless the regime flips again:
// From lib/signalOps.ts
const ONE_DAY_MS = 24 * 60 * 60 * 1000;

const shouldCreateAlert = (
  payload: SignalAlertPayload,
  latestAlert?: RegimeAlertEvent
) => {
  const hasRequiredTrigger = payload.reasons.some((reason) =>
    [
      "regime-change",
      "tightness-upshift",
      "tightness-downshift",
      "risk-appetite-upshift",
      "risk-appetite-downshift",
    ].includes(reason.code)
  );

  if (!hasRequiredTrigger) return false;
  if (!latestAlert) return true;

  const withinCooldown = Date.now() - Date.parse(latestAlert.createdAt) < ONE_DAY_MS;
  const regimeFlippedAgain =
    payload.currentAssessment.regime !== latestAlert.payload.currentAssessment.regime;

  return !withinCooldown || regimeFlippedAgain;
};

Webhook Payload

When an alert is triggered, Whether delivers a webhook with this structure:
{
  id: string;
  createdAt: string; // ISO 8601 timestamp
  payload: {
    previousRecordDate: string;
    currentRecordDate: string;
    previousAssessment: {
      regime: string;
      scores: { tightness: number; riskAppetite: number };
      description: string;
    };
    currentAssessment: {
      regime: string;
      scores: { tightness: number; riskAppetite: number };
      description: string;
    };
    reasons: Array<{
      code: string;
      message: string;
    }>;
    sourceUrls: string[];
    timeMachineHref: string;
  };
}

Example Payloads

Regime Change Alert

{
  "id": "alert_1234567890",
  "createdAt": "2025-03-03T10:00:00.000Z",
  "payload": {
    "previousRecordDate": "2025-02-21",
    "currentRecordDate": "2025-02-28",
    "previousAssessment": {
      "regime": "CONSTRAINED_OPTIMISTIC",
      "scores": { "tightness": 1.45, "riskAppetite": 0.82 },
      "description": "Tight policy with strong risk appetite"
    },
    "currentAssessment": {
      "regime": "CONSTRAINED_CAUTIOUS",
      "scores": { "tightness": 1.52, "riskAppetite": -0.23 },
      "description": "Tight policy with weak risk appetite"
    },
    "reasons": [
      {
        "code": "regime-change",
        "message": "Regime shifted from CONSTRAINED_OPTIMISTIC to CONSTRAINED_CAUTIOUS"
      },
      {
        "code": "risk-appetite-downshift",
        "message": "Risk appetite crossed below threshold (0.82 → -0.23)"
      }
    ],
    "sourceUrls": [
      "https://fiscaldata.treasury.gov/datasets/daily-treasury-par-yield-curve-rates/"
    ],
    "timeMachineHref": "/report/time-machine?date=2025-02-28"
  }
}

API Endpoints

Get Recent Alerts

GET /api/regime-alerts
Response:
{
  "alerts": [
    {
      "id": "alert_1234567890",
      "createdAt": "2025-03-03T10:00:00.000Z",
      "payload": { /* ... */ }
    }
  ]
}

Create Alert (Internal)

POST /api/regime-alerts
Request Body:
{
  previousRecordDate: string;
  currentRecordDate: string;
  previousAssessment: RegimeAssessment;
  currentAssessment: RegimeAssessment;
  reasons: Array<{ code: string; message: string }>;
  sourceUrls: string[];
  timeMachineHref: string;
}

Manage Alert Preferences

GET /api/alert-preferences?clientId={clientId}
POST /api/alert-preferences
Request Body (POST):
{
  "clientId": "user_abc123",
  "preferences": {
    "slack": true,
    "email": true,
    "webhook": false
  }
}

Deliver Alert

POST /api/alert-deliveries
Request Body:
{
  "clientId": "user_abc123",
  "alertId": "alert_1234567890",
  "channels": ["slack", "email", "webhook"]
}
Response:
{
  "deliveries": [
    {
      "id": "delivery_001",
      "alertId": "alert_1234567890",
      "channel": "slack",
      "deliveredAt": "2025-03-03T10:01:00.000Z",
      "status": "sent",
      "summary": "CONSTRAINED_CAUTIOUS (2025-02-28) · regime-change, risk-appetite-downshift"
    }
  ],
  "preferences": {
    "slack": true,
    "email": true,
    "webhook": false
  }
}

Webhook Handlers

const express = require("express");
const app = express();

app.post("/webhooks/whether", express.json(), (req, res) => {
  const alert = req.body;

  console.log("Whether regime alert:", {
    id: alert.id,
    regime: alert.payload.currentAssessment.regime,
    reasons: alert.payload.reasons.map(r => r.code)
  });

  // Process alert (send notifications, update dashboards, etc.)
  processRegimeChange(alert);

  res.status(200).json({ received: true });
});

app.listen(3000);

Integration Examples

Slack Notifications

const sendSlackAlert = async (alert: RegimeAlertEvent) => {
  const { payload } = alert;
  const { currentAssessment, reasons } = payload;

  const blocks = [
    {
      type: "header",
      text: {
        type: "plain_text",
        text: `Regime Change: ${currentAssessment.regime}`,
      },
    },
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: `*${payload.previousAssessment.regime}* → *${currentAssessment.regime}*`,
      },
    },
    {
      type: "section",
      fields: [
        {
          type: "mrkdwn",
          text: `*Tightness:*\n${currentAssessment.scores.tightness.toFixed(2)}`,
        },
        {
          type: "mrkdwn",
          text: `*Risk Appetite:*\n${currentAssessment.scores.riskAppetite.toFixed(2)}`,
        },
      ],
    },
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: `*Reasons:*\n${reasons.map((r) => `• ${r.message}`).join("\n")}`,
      },
    },
    {
      type: "actions",
      elements: [
        {
          type: "button",
          text: { type: "plain_text", text: "View Report" },
          url: `https://whether.fyi${payload.timeMachineHref}`,
        },
      ],
    },
  ];

  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ blocks }),
  });
};

Email Notifications

import nodemailer from "nodemailer";

const sendEmailAlert = async (alert: RegimeAlertEvent) => {
  const { payload } = alert;
  const { currentAssessment, reasons } = payload;

  const transport = nodemailer.createTransport({
    host: process.env.SMTP_HOST,
    port: 587,
    auth: {
      user: process.env.SMTP_USER,
      pass: process.env.SMTP_PASS,
    },
  });

  const html = `
    <h2>Whether Regime Change Alert</h2>
    <p><strong>${payload.previousAssessment.regime}</strong> → <strong>${currentAssessment.regime}</strong></p>
    <h3>Details</h3>
    <ul>
      <li><strong>Tightness:</strong> ${currentAssessment.scores.tightness.toFixed(2)}</li>
      <li><strong>Risk Appetite:</strong> ${currentAssessment.scores.riskAppetite.toFixed(2)}</li>
    </ul>
    <h3>Reasons</h3>
    <ul>
      ${reasons.map((r) => `<li><strong>${r.code}:</strong> ${r.message}</li>`).join("")}
    </ul>
    <p><a href="https://whether.fyi${payload.timeMachineHref}">View full report</a></p>
  `;

  await transport.sendMail({
    from: "[email protected]",
    to: "[email protected]",
    subject: `Whether: Regime changed to ${currentAssessment.regime}`,
    html,
  });
};

Discord Webhook

const sendDiscordAlert = async (alert: RegimeAlertEvent) => {
  const { payload } = alert;
  const { currentAssessment, reasons } = payload;

  const embed = {
    title: "Whether Regime Change",
    description: `**${payload.previousAssessment.regime}** → **${currentAssessment.regime}**`,
    color: 0x3b82f6, // blue
    fields: [
      {
        name: "Tightness",
        value: currentAssessment.scores.tightness.toFixed(2),
        inline: true,
      },
      {
        name: "Risk Appetite",
        value: currentAssessment.scores.riskAppetite.toFixed(2),
        inline: true,
      },
      {
        name: "Reasons",
        value: reasons.map((r) => `• **${r.code}:** ${r.message}`).join("\n"),
      },
    ],
    url: `https://whether.fyi${payload.timeMachineHref}`,
    timestamp: alert.createdAt,
  };

  await fetch(process.env.DISCORD_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ embeds: [embed] }),
  });
};

Security

Request Validation

Validate incoming webhooks to ensure they’re from Whether:
import { z } from "zod";

const alertSchema = z.object({
  id: z.string(),
  createdAt: z.string().datetime(),
  payload: z.object({
    previousRecordDate: z.string(),
    currentRecordDate: z.string(),
    previousAssessment: z.object({
      regime: z.string(),
      scores: z.object({
        tightness: z.number(),
        riskAppetite: z.number(),
      }),
    }),
    currentAssessment: z.object({
      regime: z.string(),
      scores: z.object({
        tightness: z.number(),
        riskAppetite: z.number(),
      }),
    }),
    reasons: z.array(
      z.object({
        code: z.string(),
        message: z.string(),
      })
    ),
  }),
});

export async function POST(request: Request) {
  const body = await request.json();

  // Validate structure
  const result = alertSchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json(
      { error: "Invalid payload", details: result.error },
      { status: 400 }
    );
  }

  // Process the alert
  await handleAlert(result.data);

  return NextResponse.json({ received: true });
}

IP Allowlisting

Restrict webhook endpoints to Whether’s infrastructure:
const ALLOWED_IPS = ["1.2.3.4", "5.6.7.8"]; // Whether IP ranges

export async function POST(request: Request) {
  const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip");

  if (!ip || !ALLOWED_IPS.includes(ip)) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  // Process webhook
}

Polling Alternative

If webhooks aren’t feasible, poll the agent API and check for new alerts:
const checkForNewAlerts = async () => {
  const response = await fetch("https://whether.fyi/api/regime-alerts");
  const { alerts } = await response.json();

  const latest = alerts[0];
  const lastSeen = await getLastSeenAlertId();

  if (latest && latest.id !== lastSeen) {
    await handleRegimeChange(latest);
    await saveLastSeenAlertId(latest.id);
  }
};

// Poll every hour
setInterval(checkForNewAlerts, 60 * 60 * 1000);

Build docs developers (and LLMs) love