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
{
"alerts": [
{
"id": "alert_1234567890",
"createdAt": "2025-03-03T10:00:00.000Z",
"payload": { /* ... */ }
}
]
}
Create Alert (Internal)
POST /api/regime-alerts
{
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
{
"clientId": "user_abc123",
"preferences": {
"slack": true,
"email": true,
"webhook": false
}
}
Deliver Alert
POST /api/alert-deliveries
{
"clientId": "user_abc123",
"alertId": "alert_1234567890",
"channels": ["slack", "email", "webhook"]
}
{
"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
- Express.js
- Next.js API Route
- Python Flask
- Cloudflare Worker
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);
// app/api/webhooks/whether/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const alert = await request.json();
// Validate alert structure
if (!alert.id || !alert.payload) {
return NextResponse.json(
{ error: "Invalid alert payload" },
{ status: 400 }
);
}
// Process regime change
await handleRegimeChange({
alertId: alert.id,
previousRegime: alert.payload.previousAssessment.regime,
currentRegime: alert.payload.currentAssessment.regime,
reasons: alert.payload.reasons,
});
return NextResponse.json({ received: true });
}
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/webhooks/whether", methods=["POST"])
def whether_webhook():
alert = request.get_json()
alert_id = alert.get("id")
payload = alert.get("payload", {})
current = payload.get("currentAssessment", {})
reasons = payload.get("reasons", [])
print(f"Whether alert {alert_id}: {current.get('regime')}")
for reason in reasons:
print(f" - {reason['code']}: {reason['message']}")
# Process the alert
process_regime_change(alert)
return jsonify({"received": True}), 200
if __name__ == "__main__":
app.run(port=3000)
export default {
async fetch(request: Request): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const alert = await request.json();
// Process the alert
await handleRegimeChange(alert);
return Response.json({ received: true });
},
};
async function handleRegimeChange(alert: any) {
const { payload } = alert;
const regime = payload.currentAssessment.regime;
const reasons = payload.reasons.map((r: any) => r.code);
console.log(`Regime change: ${regime} (${reasons.join(", ")})`);
// Send to Slack, Discord, etc.
}
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);
Related
- Agent Interface - Programmatic access for AI agents
- API Reference - Complete endpoint documentation