Webhook Agent Loop — Event-Driven

A Node.js/Express server that receives jettyd webhook events and triggers commands in response. Build reactive automations without polling — respond to device events in real time.

Prerequisites Node.js 18+, a jettyd API key, and a publicly reachable URL for your webhook server (use ngrok for local development).

How it works

  1. jettyd delivers webhook events (telemetry alerts, device status changes) to your URL via HTTP POST
  2. Your server validates the payload and executes business logic
  3. If the logic decides to act, it calls the jettyd REST API to send a device command
  4. The loop continues — the command may trigger further telemetry, which may trigger further webhooks

Project setup

mkdir jettyd-webhook-agent && cd jettyd-webhook-agent
npm init -y
npm install express
npm install --save-dev typescript @types/express @types/node ts-node

The TypeScript server

// src/server.ts
import express, { Request, Response } from 'express';

const app = express();
app.use(express.json());

const JETTYD_API_BASE = 'https://api.jettyd.com/v1';
const JETTYD_API_KEY  = process.env['JETTYD_API_KEY'] ?? '';

if (!JETTYD_API_KEY) {
  console.error('JETTYD_API_KEY is required');
  process.exit(1);
}

// ── jettyd API helper ─────────────────────────────────────────────────────────

async function sendCommand(
  deviceId: string,
  action: string,
  params: Record<string, unknown> = {},
): Promise<void> {
  const res = await fetch(`${JETTYD_API_BASE}/devices/${deviceId}/commands`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${JETTYD_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ command_type: action, payload: params }),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Command failed ${res.status}: ${text}`);
  }
  console.log(`[${new Date().toISOString()}] command sent: ${action}`, params);
}

// ── Webhook handler ───────────────────────────────────────────────────────────

interface JettydWebhookEvent {
  event: string;
  device_id: string;
  payload: Record<string, unknown>;
  timestamp: string;
}

app.post('/webhook/jettyd', async (req: Request, res: Response) => {
  const event = req.body as JettydWebhookEvent;

  console.log(`[${new Date().toISOString()}] event=${event.event} device=${event.device_id}`);

  // Acknowledge immediately — jettyd retries on non-2xx within 30s
  res.status(200).json({ ok: true });

  // Handle events asynchronously so we don't block the response
  handleEvent(event).catch((err) => {
    console.error('handleEvent error:', err);
  });
});

async function handleEvent(event: JettydWebhookEvent): Promise<void> {
  const { event: eventType, device_id, payload } = event;

  switch (eventType) {
    case 'telemetry.alert': {
      // A JettyScript threshold rule fired on the device
      const metric = payload['metric'] as string | undefined;
      const value  = payload['value']  as number | undefined;

      if (metric === 'soil.moisture' && value != null && value < 25) {
        console.log(`Low moisture alert on ${device_id}: ${value}%`);
        await sendCommand(device_id, 'valve.on', { duration_s: 180 });
      }
      break;
    }

    case 'device.offline': {
      // Device went offline — log and optionally notify
      console.warn(`Device ${device_id} went offline at ${event.timestamp}`);
      // Add your alerting logic here (Slack, PagerDuty, email, etc.)
      break;
    }

    case 'device.online': {
      // Device came back online — re-apply config if needed
      console.info(`Device ${device_id} is back online`);
      break;
    }

    default:
      console.log(`Unhandled event: ${eventType}`);
  }
}

// ── Start ─────────────────────────────────────────────────────────────────────

const PORT = Number(process.env['PORT'] ?? 3000);
app.listen(PORT, () => {
  console.log(`jettyd webhook agent listening on :${PORT}`);
});

Running locally with ngrok

# Terminal 1 — start the server
export JETTYD_API_KEY=tk_your_key_here
npx ts-node src/server.ts

# Terminal 2 — expose it publicly
ngrok http 3000

ngrok prints a public URL like https://abc123.ngrok.io. Use that as your webhook URL.

Register the webhook with jettyd

curl -X POST https://api.jettyd.com/v1/webhooks \
  -H "Authorization: Bearer $JETTYD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.io/webhook/jettyd",
    "events": ["telemetry.alert", "device.offline", "device.online"]
  }'

Testing with a mock event

curl -X POST http://localhost:3000/webhook/jettyd \
  -H "Content-Type: application/json" \
  -d '{
    "event": "telemetry.alert",
    "device_id": "your-device-uuid",
    "payload": { "metric": "soil.moisture", "value": 18 },
    "timestamp": "2025-01-15T10:30:00Z"
  }'

Deploying to production

Deploy to any Node.js host (Railway, Render, Fly.io, AWS Lambda). Replace ngrok with your production URL and update the webhook registration in jettyd.

Security note In production, validate the webhook signature using the secret from jettyd. Reject payloads without a valid X-Jettyd-Signature header to prevent spoofed events.

Next: LangChain Integration →