Replace your monthly analytics reports with a single Trigger.dev task

Every month you pull the same numbers, format the same report, and email the same clients. That's not a task, that's a cron job. Trigger.dev is an open-source background jobs platform for TypeScript — you write tasks as async functions, deploy them alongside your app, and it handles scheduling, retries, and logging. One scheduled task, about 70 lines, pulls analytics from Lodd's REST API, formats the report, and sends it via Resend. Deploy once, it runs itself.

I'll walk you through building it step by step, then show the full code at the end for copy-pasting. You can have this running in production in about 20 minutes.

Start with the task skeleton

A Trigger.dev scheduled task is just an async function with a cron expression:

import { schedules }
  from "@trigger.dev/sdk/v3";

const LODD = "https://api.lodd.dev/v1";

export const monthlyReport = schedules.task({
  id: "monthly-traffic-report",
  // 1st of each month at 9am UTC
  cron: "0 9 1 * *",
  run: async () => {
    // we'll fill this in
  },
});

That's the whole framework. Everything else is what goes inside run.

Pull the analytics data

Lodd's REST API returns flat JSON with API key auth. Four calls get you everything for a monthly report: current analytics, previous period for comparison, top pages, and traffic sources.

const headers = {
  "X-API-Key": process.env.LODD_API_KEY!
};
const siteId = process.env.LODD_SITE_ID!;

Site IDs are UUIDs. Get yours from GET /v1/sites which returns all sites on your account. Lodd is a web analytics API with 49 REST endpoints. Full docs.

Build the date ranges for current and previous month, then fetch all four in parallel:

const now = new Date();
const fmt = (d: Date) =>
  d.toISOString().split("T")[0];
const curStart = new Date(
  now.getFullYear(),
  now.getMonth() - 1, 1
);
const curEnd = new Date(
  now.getFullYear(),
  now.getMonth(), 0
);
const prevStart = new Date(
  now.getFullYear(),
  now.getMonth() - 2, 1
);
const prevEnd = new Date(
  now.getFullYear(),
  now.getMonth() - 1, 0
);
const currentPeriod =
  `${fmt(curStart)}..${fmt(curEnd)}`;
const previousPeriod =
  `${fmt(prevStart)}..${fmt(prevEnd)}`;

const [current, previous, pages, sources] =
  await Promise.all([
    fetch(
      `${LODD}/sites/${siteId}/analytics`
      + `?period=${currentPeriod}`,
      { headers }
    ).then(r => r.json()),
    fetch(
      `${LODD}/sites/${siteId}/analytics`
      + `?period=${previousPeriod}`,
      { headers }
    ).then(r => r.json()),
    fetch(
      `${LODD}/sites/${siteId}/pages`
      + `?period=${currentPeriod}`,
      { headers }
    ).then(r => r.json()),
    fetch(
      `${LODD}/sites/${siteId}/sources`
      + `?period=${currentPeriod}`,
      { headers }
    ).then(r => r.json()),
  ]);

Each response comes back as { "data": ..., "request_id": "..." }. The fields you get:

EndpointKey fields
/v1/sites/:id/analyticstotal_page_views, unique_visitors, bounce_rate, average_duration
/v1/sites/:id/pagesurl, page_title, page_views, unique_visitors, bounce_rate per page
/v1/sites/:id/sourcessource_name, source_type, unique_visitors, bounce_rate per source

The analytics endpoint returns one period per call. To compare months, make two calls with different date ranges using the YYYY-MM-DD..YYYY-MM-DD period format.

Format the report

Take the four API responses and build a plain text email:

function formatReport(
  current: any, previous: any,
  pages: any[], sources: any[]
) {
  const month = new Date()
    .toLocaleDateString("en",
      { month: "long", year: "numeric" });
  const change =
    previous.unique_visitors > 0
      ? Math.round(
          (current.unique_visitors
            - previous.unique_visitors)
          / previous.unique_visitors * 100
        )
      : 0;

  const topPages = pages.slice(0, 5)
    .map((p, i) =>
      `  ${i + 1}. ${p.url} — `
      + `${p.page_views} views`)
    .join("\n");

  const topSources = sources.slice(0, 5)
    .map((s, i) =>
      `  ${i + 1}. ${s.source_name} — `
      + `${s.unique_visitors} visitors`)
    .join("\n");

  return {
    subject: `Traffic Report — ${month}`,
    text: `Visitors: `
      + `${current.unique_visitors}`
      + ` (${change >= 0 ? "+" : ""}`
      + `${change}% vs last month)
Page views: ${current.total_page_views}
Bounce rate: `
      + `${current.bounce_rate.toFixed(1)}%
Avg duration: `
      + `${current.average_duration.toFixed(0)}s

Top Pages
${topPages}

Traffic Sources
${topSources}`,
  };
}

Here's what the output looks like with real data:

Traffic Report — May 2026

Visitors: 299 (+779% vs last month)
Page views: 526
Bounce rate: 82.9%
Avg duration: 76s

Top Pages
  1. / — 306 views
  2. /docs — 39 views
  3. /pricing — 25 views
  4. /blog — 20 views
  5. /sign-up — 12 views

Traffic Sources
  1. Direct — 231 visitors
  2. Reddit — 49 visitors
  3. Google — 2 visitors
  4. IndieHackers — 3 visitors
  5. Bluesky — 1 visitors

Add an LLM interpretation step

Raw numbers are useful. An agent explaining what they mean is better. After pulling the data, send it to Claude and get a plain-English summary:

const interpretation = await fetch(
  "https://api.anthropic.com/v1/messages",
  {
    method: "POST",
    headers: {
      "x-api-key":
        process.env.ANTHROPIC_API_KEY!,
      "content-type": "application/json",
      "anthropic-version": "2023-06-01",
    },
    body: JSON.stringify({
      model: "claude-sonnet-4-6",
      max_tokens: 500,
      messages: [{
        role: "user",
        content:
          `Analyse this website traffic `
          + `data and give 3 short insights.`
          + ` Be specific, mention numbers.`
          + ` No preamble.

Current period: `
          + `${JSON.stringify(current.data)}
Previous period: `
          + `${JSON.stringify(previous.data)}
Top pages: `
          + `${JSON.stringify(
              pages.data.slice(0, 5))}
Top sources: `
          + `${JSON.stringify(
              sources.data.slice(0, 5))}`,
      }],
    }),
  }
).then(r => r.json());

const insights =
  interpretation.content[0].text;

Now include insights in your report. The client gets numbers AND context:

Traffic Report — May 2026

Visitors: 299 (+779% vs last month)
Page views: 526
Bounce rate: 82.9%

Insights:
- Traffic grew 779% month-over-month,
  almost entirely from Reddit (49 visitors)
  and direct (231). Organic search is still
  negligible at 2 visitors.
- Bounce rate at 82.9% is high. The homepage
  accounts for 58% of all views but bounces
  79.8% of visitors.
- /docs has the lowest bounce rate (51.7%)
  and longest sessions (144s), suggesting
  visitors who reach docs are genuinely
  engaged. Consider linking to docs more
  prominently from the homepage.

The LLM call adds about $0.002 per report and 2-3 seconds of latency. Worth it for the quality difference.

Send it

Email via Resend:

const report = formatReport(
  current.data, previous.data,
  pages.data, sources.data
);

await fetch(
  "https://api.resend.com/emails",
  {
    method: "POST",
    headers: {
      Authorization:
        `Bearer ${process.env.RESEND_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from:
        "Reports <reports@yourdomain.com>",
      to: [process.env.CLIENT_EMAIL!],
      subject: report.subject,
      text: report.text,
    }),
  }
);

Or post to Slack instead:

await fetch(
  process.env.SLACK_WEBHOOK_URL!,
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      text: `*${report.subject}*\n`
        + "\`\`\`" + report.text
        + "\`\`\`",
    }),
  }
);

Set up an incoming webhook in your Slack workspace. The report shows up as a formatted code block. You can do both: email the client, post to your internal channel.

The full code

Everything together, ready to copy:

import { schedules }
  from "@trigger.dev/sdk/v3";

const LODD = "https://api.lodd.dev/v1";

export const monthlyReport = schedules.task({
  id: "monthly-traffic-report",
  cron: "0 9 1 * *",
  run: async () => {
    const headers = {
      "X-API-Key": process.env.LODD_API_KEY!
    };
    const siteId =
      process.env.LODD_SITE_ID!;

    const now = new Date();
    const fmt = (d: Date) =>
      d.toISOString().split("T")[0];
    const currentPeriod = [
      new Date(
        now.getFullYear(),
        now.getMonth() - 1, 1
      ),
      new Date(
        now.getFullYear(),
        now.getMonth(), 0
      ),
    ].map(fmt).join("..");
    const previousPeriod = [
      new Date(
        now.getFullYear(),
        now.getMonth() - 2, 1
      ),
      new Date(
        now.getFullYear(),
        now.getMonth() - 1, 0
      ),
    ].map(fmt).join("..");

    const [
      current, previous, pages, sources
    ] = await Promise.all([
      fetch(
        `${LODD}/sites/${siteId}`
        + `/analytics`
        + `?period=${currentPeriod}`,
        { headers }
      ).then(r => r.json()),
      fetch(
        `${LODD}/sites/${siteId}`
        + `/analytics`
        + `?period=${previousPeriod}`,
        { headers }
      ).then(r => r.json()),
      fetch(
        `${LODD}/sites/${siteId}/pages`
        + `?period=${currentPeriod}`,
        { headers }
      ).then(r => r.json()),
      fetch(
        `${LODD}/sites/${siteId}/sources`
        + `?period=${currentPeriod}`,
        { headers }
      ).then(r => r.json()),
    ]);

    const report = formatReport(
      current.data, previous.data,
      pages.data, sources.data
    );

    await fetch(
      "https://api.resend.com/emails",
      {
        method: "POST",
        headers: {
          Authorization:
            `Bearer `
            + `${process.env.RESEND_API_KEY}`,
          "Content-Type":
            "application/json",
        },
        body: JSON.stringify({
          from: "Reports "
            + "<reports@yourdomain.com>",
          to: [process.env.CLIENT_EMAIL!],
          subject: report.subject,
          text: report.text,
        }),
      }
    );
  },
});

Scale to all clients

Fetch all sites and loop:

const { data: sites } = await fetch(
  `${LODD}/sites`, { headers }
).then(r => r.json());

for (const site of sites) {
  const [
    current, previous, pages, sources
  ] = await Promise.all([
    fetch(
      `${LODD}/sites/${site.id}`
      + `/analytics`
      + `?period=${currentPeriod}`,
      { headers }
    ).then(r => r.json()),
    // ... same pattern for the other 3
  ]);

  const report = formatReport(
    current.data, previous.data,
    pages.data, sources.data
  );
  await sendEmail(
    getClientEmail(site.domain), report
  );
}

One task, one deploy, all clients. The getClientEmail mapping is yours — a simple object works at small scale.

Beyond reports: daily health checks

The same API supports a daily check that catches problems early:

export const healthCheck = schedules.task({
  id: "daily-health-check",
  cron: "0 9 * * *",
  run: async () => {
    const headers = {
      "X-API-Key":
        process.env.LODD_API_KEY!
    };
    const { data } = await fetch(
      `${LODD}/sites/${siteId}/snapshot`,
      { headers }
    ).then(r => r.json());

    if (
      data.visitor_change_percent < -30
    ) {
      // Post to Slack or create
      // a GitHub issue
    }
  },
});

Traffic dropped 30%+ overnight? That's probably a deploy issue or a lost referrer. Better to catch it at 9am than hear about it from a client.

Take it further

The analytics API + Trigger.dev + an LLM is a flexible stack. A few ideas:

  • Combine with Google Search Console — pull ranking data alongside traffic. "You ranked #4 for 'web analytics MCP' but got 0 clicks. The title might need work." GSC has an MCP server and a REST API.
  • Post-deploy health checks — trigger a task from your Vercel/Netlify deploy webhook. Pull a snapshot 30 minutes after deploy, compare to the previous day, alert if bounce rate spiked.
  • GitHub issue creation — when the LLM flags a problem, auto-create a GitHub issue with the data and the insight. The issue arrives in your backlog already triaged.
  • Weekly content performance — pull page-level data, combine with GSC queries, and have Claude suggest which blog posts to update and what new topics to target.
  • Anomaly detection — run a daily task that compares this week to last week across all metrics. Only alert when something is significantly different. The LLM filters noise from signal.

Each of these is the same pattern: scheduled task, pull data from APIs, optionally interpret with an LLM, send somewhere useful.

MCP for interactive, REST for automated

Lodd also has 42 MCP tools for interactive agent sessions — asking your coding agent about traffic mid-conversation. The REST API is for automation: scheduled tasks that run without you. Same data, same API key, different interfaces.

Prefer a visual builder? An n8n version of this workflow is coming soon.