Every billing alert ever written assumes somebody reads it. That’s the whole design: a number crosses a line, an email goes out, a human does something. It works fine at 2pm on a Tuesday. It works a lot worse at 3am on a Saturday, which is when retry loops, crawlers and recursive triggers seem to prefer doing their work.

This post covers two things. First, why alert-only setups fail and what to put on top of them. Second, a practical walkthrough for the part everyone should do anyway: turning on Cloudflare’s own free billing alerts and routing them somewhere a person will actually see them.

Why alert-only fails at 3am

Who’s awake? An email to the billing address lands in an inbox that one person checks during working hours. If that person is asleep, on a plane or on holiday, the alert has done its job technically and nothing at all in practice. Billing emails are rarely wired into whatever pages your on-call engineer, because nobody thinks of a bill as an incident until it is one.

Daily evaluation. Cloudflare’s budget alerts are a real improvement, and you should turn them on (steps below). But Cloudflare processes usage once a day, so per its June 2026 changelog an alert fires the day after the threshold is reached, not when it happens. For a slow drift that’s fine. For a loop, a day is the whole incident.

To put a number on it: a bug that writes 2,000 D1 rows a second writes about 172.8 million rows a day. At D1’s published rate of $1.00 per million rows written past the 50 million included each month, that’s roughly $123 the first day and about $173 every day after. Those are estimates from list prices, not an invoice, but the shape is the point: by the time a daily alert arrives, you’ve already paid for the day.

Alert fatigue. The third failure is quieter. A team that gets a “spend is up” email every week for normal growth learns to archive them unread. Then the one that matters gets archived too. We wrote a whole separate post on designing alerts people don’t mute.

None of this means alerts are useless. It means an alert is the first rung of a ladder, not the whole ladder.

The escalation ladder

Think of it as four rungs. Each one is louder and more disruptive than the last, and you only climb when the rung below didn’t fix it.

  1. Notify. Something is moving. Post it to a channel the team reads. Nobody gets woken up. Example: 50% of the monthly budget used, or a forecast that says you’ll cross the cap before month end.
  2. Page. Something is moving fast enough that it can’t wait for morning. A specific person gets paged through whatever your on-call tool is. Example: 100% of budget, or a spike where the last few hours cost four times the normal rate.
  3. Degrade. Your app sheds cost on its own before anyone touches it. Turn off the AI summary, pause preview builds, serve stale cache, go read-only. This needs your code to know the budget state, which is what a status API is for.
  4. Cap. Cut the billing source at the platform. Remove the Worker route, switch on a blocking WAF rule, pause the cron. This is an outage you chose on purpose, because the alternative was a bigger bill.

The rungs are cumulative. When you reach “cap” you should still have been notified and paged on the way up, so a human knows the cap fired and can undo it.

Most teams stop at rung one. Cloudflare’s built-in alerts cover rung one, by email, once a day. The rest of this post gets you a reliable rung one and two, and the end of it covers three and four.

Step 1: Turn on Cloudflare’s budget alerts

Do this first, whatever else you run. It’s free.

Who can use it: budget alerts are for Pay-as-you-go accounts. Enterprise contract accounts aren’t supported, per the budget alerts docs.

What it covers: account-wide spend on usage-based products for the current billing period. Subscription fees, such as the Workers Paid plan fee, don’t count toward the threshold. It’s informational only; it never pauses or caps anything.

How to set it up, as documented:

  1. Log in to the Cloudflare dashboard and select your account.
  2. Go to Manage Account > Billing, then open Billable Usage.
  3. Select Create budget alert.
  4. Enter an alert name, an optional description, and the budget threshold in USD.
  5. Add email recipients. Select Add email to include more than one address.
  6. Save.

Since June 2026 you can also start this from the sidebar of product pages like Workers, D1 and R2 (changelog). Don’t be misled by where you clicked: the alert still watches total account spend, not just that product.

When spend crosses the threshold, Cloudflare sends one email to every recipient, and the alert resets each billing period. Cloudflare is also rolling out a default $10 alert to eligible Pay-as-you-go accounts that don’t have one (changelog). If you already have one, check the threshold and recipients are what you want rather than what you set up once and forgot.

A tip: create two or three alerts at different thresholds, say 50%, 100% and 150% of what you expect to spend. Each one fires once per billing period, so a single alert tells you only that you crossed one line, once.

Step 2: Usage notifications for single products

Separately from budget alerts, Cloudflare has usage-based billing notifications. These watch one product metric (bytes, requests, queries, minutes) instead of dollars. The docs describe them for add-on usage on Professional plans and above, and send them to the billing email on file.

To set one up:

  1. In the dashboard, select your account and go to Notifications.
  2. Select Add.
  3. Choose the Usage Based Billing notification type.
  4. Fill in a name, the product, the notification limit (the metric depends on the product), and the notification email.
  5. Save.

Which products appear in that list depends on your account, so open it and look rather than assuming. If you want a dollar view across everything, that’s what budget alerts are for.

Step 3: Get those emails somewhere a human will see them

Both of Cloudflare’s billing alerts are email. The trick is to make “email” mean “Slack message” or “PagerDuty incident”. You add a dedicated address as a recipient and make that address deliver to the right place.

Cloudflare’s Notifications service supports webhooks on Professional plans and above and PagerDuty on Business and above, for the notification types that offer them. Budget alerts are documented as email, so the options below assume email.

Option A: an email-to-channel address

The simplest route needs no code. Slack can give a channel its own email address: open the channel’s settings, find the integrations section, and choose to send emails to the channel. Slack generates an address; anything sent there gets posted in the channel. Workspace owners and admins can restrict who creates these addresses, so you may need to ask.

PagerDuty has the same idea: add an Email integration to a service and PagerDuty gives you an address that opens an incident for each email it receives. Use that one for your top-threshold alert only, otherwise you’re paging people for FYIs.

Add those addresses as recipients on the budget alert. Done.

Option B: Cloudflare Email Routing and an Email Worker

If you want control over the message, filtering, or delivery to a webhook, route the email through a Worker. This uses Email Routing, which is free but needs the domain onboarded with Cloudflare so it can add the MX, SPF and DKIM records.

  1. In the dashboard, go to Email Routing for your domain and enable it, adding the records it asks for.
  2. Deploy the Worker below.
  3. Under Routing Rules, select Create address, enter a local part such as cf-billing, set the action to Send to a Worker, and pick the Worker.
  4. Add cf-billing@yourdomain.com as a budget alert recipient.

The Worker posts a short message to a Slack incoming webhook, and forwards the original email to a human inbox so there’s always a copy.

interface Env {
  SLACK_WEBHOOK_URL: string;
  FORWARD_TO: string;
  ALLOWED_SENDER_DOMAIN: string;
}

export default {
  async email(message, env, ctx) {
    if (!message.from.toLowerCase().endsWith(`@${env.ALLOWED_SENDER_DOMAIN}`)) {
      message.setReject('Sender not allowed');
      return;
    }

    const subject = message.headers.get('subject') ?? '(no subject)';
    ctx.waitUntil(
      fetch(env.SLACK_WEBHOOK_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: `Cloudflare billing alert: ${subject}` }),
      }),
    );

    await message.forward(env.FORWARD_TO);
  },
} satisfies ExportedHandler<Env>;

A few notes:

  • FORWARD_TO must be a verified destination address in Email Routing, or forward() will throw. Store SLACK_WEBHOOK_URL as a secret (wrangler secret put SLACK_WEBHOOK_URL), not a plain variable.
  • Set ALLOWED_SENDER_DOMAIN to the domain your real Cloudflare billing emails come from. Check one you’ve received rather than guessing. Without a sender check, anyone who learns the address can post into your channel.
  • The full message body is available as a stream on message.raw if you want to parse it, for example with a MIME parser library.

To page instead of post, swap the Slack call for PagerDuty’s Events API v2:

await fetch('https://events.pagerduty.com/v2/enqueue', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    routing_key: env.PAGERDUTY_ROUTING_KEY,
    event_action: 'trigger',
    dedup_key: `cf-budget-${subject}`,
    payload: { summary: `Cloudflare billing alert: ${subject}`, source: 'cloudflare-billing', severity: 'critical' },
  }),
});

You can test the Worker locally: wrangler dev exposes an endpoint at /cdn-cgi/handler/email that accepts a raw email POST and runs your email() handler.

Step 4: Make SignalFlare page the right person

Cloudflare’s alerts answer “did the account cross a dollar line yesterday”. SignalFlare is built for the faster, narrower question: which service is moving, right now, and who needs to know.

On paid plans we poll your usage every 5 minutes. Cloudflare’s analytics lag a few minutes and are sampled, so every number we show is an estimate, an early warning and not your invoice. Within that, here’s how to set alerts up so the right person hears about the right thing:

  • Per-service budgets. Put a budget on D1 alone, or R2 alone, instead of only the whole account. A D1 runaway then trips the D1 budget long before it moves the account total enough to notice.
  • Thresholds. The default is 50%, 80% and 100%, and each one fires at most once per budget per month. If spend jumps straight past several in one poll, only the highest one fires.
  • Forecast. Turn on projection alerts and you hear once a month when the trend says you’ll cross the cap, before you actually do.
  • Spike. Separately, we alert when the last three hours cost at least four times the prior day’s hourly rate (and at least 10 cents an hour, so a jump on pennies stays quiet), once per account per day.
  • Channels. Email, Slack, Discord, or a signed webhook. Each budget picks its own channels, so the FYI budget goes to #cloudflare-spend and the hard-cap budget goes to a webhook that pages.

The webhook is how you reach PagerDuty, Opsgenie or anything else. Every request carries an X-SignalFlare-Signature header: the hex HMAC-SHA256 of the raw body, keyed with the secret you set on the channel. A small Worker can verify it and forward only critical alerts to PagerDuty:

interface Env {
  SIGNALFLARE_WEBHOOK_SECRET: string;
  PAGERDUTY_ROUTING_KEY: string;
}

function hexToBytes(hex: string): Uint8Array {
  const pairs = hex.match(/.{2}/g) ?? [];
  return new Uint8Array(pairs.map((pair) => parseInt(pair, 16)));
}

async function isSigned(body: string, signature: string, secret: string): Promise<boolean> {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
  return crypto.subtle.verify('HMAC', key, hexToBytes(signature), enc.encode(body));
}

export default {
  async fetch(request, env) {
    const body = await request.text();
    const signature = request.headers.get('X-SignalFlare-Signature') ?? '';
    if (!(await isSigned(body, signature, env.SIGNALFLARE_WEBHOOK_SECRET))) {
      return new Response('bad signature', { status: 401 });
    }

    const { alert } = JSON.parse(body);
    const critical = alert.kind === 'kill_switch' || alert.kind === 'spike' || (alert.kind === 'threshold' && alert.threshold >= 100);
    if (critical) {
      await fetch('https://events.pagerduty.com/v2/enqueue', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          routing_key: env.PAGERDUTY_ROUTING_KEY,
          event_action: 'trigger',
          payload: { summary: alert.message, source: 'signalflare', severity: 'critical' },
        }),
      });
    }
    return new Response('ok');
  },
} satisfies ExportedHandler<Env>;

crypto.subtle.verify compares in constant time, so you don’t need to roll your own comparison.

Before you rely on any of this, use the Send test button on the channel. A pager route that has never fired is a pager route you don’t know works.

Rungs three and four

Paging gets a human involved within minutes. It doesn’t help if the human is asleep through it, or if the thing burning money is faster than anyone can log in.

Degrade. SignalFlare’s budget status API returns a level for each budget (ok, warn, critical, exceeded) that your code can read with a status key and act on: skip the AI call at warn, go read-only at exceeded. It’s an estimate like everything else, refreshed once per poll, so cache it for a minute and design for “roughly right, soon” rather than exact.

Cap. This is the part Cloudflare doesn’t do for you. Finally, hard billing caps for Cloudflare: when a budget reaches 100%, SignalFlare can take action at the Cloudflare API, using a separate write token you grant on purpose (the read-only token you start with can’t change anything). Today it can:

  • remove a Worker route,
  • enable a pre-built WAF rule that blocks or rate-limits,
  • pause a Worker’s cron triggers,
  • turn off a Worker’s workers.dev URL.

Every action starts as a dry run, so you see exactly what it would have done. Arming it takes a typed confirmation, only org owners and admins can do it, every execution is written to an audit log with Cloudflare’s raw responses, and restoring is one click.

The honest limits: the cap trips on the poll after the estimate crosses 100%, so with 5-minute polling and a few minutes of analytics lag, you’ll overshoot a little. It can’t stop storage billing, which keeps accruing while data sits there. It can’t stop work that never passes through a route, like Durable Object alarms or Queue retries. And a Worker on a Custom Domain isn’t served through a route, so removing routes won’t stop it. For those, see our mitigation recipes.

The short version

  • Turn on Cloudflare’s budget alerts. They’re free, and Cloudflare may already have made you one.
  • Get the email into Slack or PagerDuty, either with an email-to-channel address or an Email Worker.
  • Climb the ladder: notify, page, degrade, cap. Decide in advance which alert does what.
  • Test the page route before you need it.

SignalFlare watches your Cloudflare spend, alerts in your team’s channels, and can cap it when a budget hits 100%. Estimates, not invoices. Start a 14-day trial.