Blog · Engineering

Push notifications from Cloudflare Workers

Send push notifications from your Cloudflare Workers app: the key as a secret, a fetch helper, routes, Cron Triggers and queues that never block, and signed webhooks.

Leer en español

If your app runs on Cloudflare Workers, sending a push notification when something happens in your business (a payment, a new message, an appointment tomorrow) comes down to one fetch call to a sending API with your secret key. In this guide you wire your Worker's events to castly: the key as a secret, a reusable helper, sends from routes, Cron Triggers and your own queue without making users wait, and receiving signed webhooks.

In short:

  • Store the sk_live_… secret key with wrangler secret put; the pk_live_… public key is the only one that reaches the browser.
  • A few-line helper wraps POST /v1/notifications and tells apart 202, 429 rate_limited, 429 quota_exceeded and 403 org_suspended.
  • Use ctx.waitUntil or your own queue so the send never delays the response, and a Cron Trigger for reminders.
  • Derive the Idempotency-Key from your business event's ID so a retry never duplicates the alert.
  • Verify webhook signatures with crypto.subtle.verify before trusting them.

What the frontend needs

The Worker only sends; subscribing happens in the browser. In your frontend you load the SDK, initialize it with the public key and call Castly.subscribe() from a click, passing the same user ID your backend uses:

<script src="https://castly.sh/sdk/v1/castly.js"></script>
<script>
  Castly.init({ publicKey: 'pk_live_…' });
  document.querySelector('#enable-alerts').addEventListener('click', () =>
    Castly.subscribe({ userId: currentUser.id })
  );
</script>

Your site also serves /castly-sw.js from the root with a single line: importScripts('https://castly.sh/sdk/v1/sw.js');. If you deploy the frontend with Workers Static Assets or Pages, dropping that file into your project's public folder is enough for it to be served as is. Make sure your SPA routing doesn't answer that path with index.html, or the browser will refuse to register it. We cover the details in the service worker in push notifications.

The secret key, as a Worker secret

The sk_live_… key can send to every subscriber you have, so treat it like a password. On Workers, it belongs in a secret:

npx wrangler secret put CASTLY_SECRET_KEY
npx wrangler secret put CASTLY_WEBHOOK_SECRET

Secrets reach your code as properties of env, just like variables, but they don't appear in wrangler.jsonc or in plain text in the Cloudflare dashboard. Locally, put them in .dev.vars and add that file to .gitignore. Use one key per environment: a test project's key on staging and the production key only in production, so a test never reaches real users. If a key leaks, revoke it in the dashboard and create a new one.

A helper that sends with fetch

Put the call in one function so every route shares headers, error handling and idempotency:

// src/push.js
export class PushError extends Error {
  constructor(status, code, retryable) {
    super(`push ${status} ${code}`);
    this.status = status;
    this.code = code;
    this.retryable = retryable;
  }
}

export async function sendPush(env, notification, idempotencyKey) {
  const res = await fetch('https://castly.sh/v1/notifications', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${env.CASTLY_SECRET_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify(notification),
  });
  if (res.status === 202) return res.json(); // { id, status: 'queued' }
  const { error } = await res.json().catch(() => ({}));
  const code = error?.code ?? 'unknown';
  // rate_limited and 5xx are temporary; quota_exceeded and org_suspended are not.
  throw new PushError(res.status, code, code === 'rate_limited' || res.status >= 500);
}

What each response means and what to do about it:

ResponseMeaningWhat to do
202Accepted: { id, status: "queued" }. Delivery to each device continues in the background.Keep the id if you want to check the outcome.
429 rate_limitedOver 300 requests per minute with the same key.Retry later with backoff.
429 quota_exceededYour plan's pushes for the month are used up.Don't retry: alert your team. Sending resumes when the month rolls over or you upgrade.
403 org_suspendedThe organization is suspended.Don't retry; check your email and the dashboard.
5xxA temporary service error.Retry with backoff.

Telling the two 429s apart is the key: one goes away if you wait, the other doesn't. If you only look at the HTTP status, you'll keep retrying a send that can't go out. The same applies to validation errors, such as a title over 120 characters: fix the alert instead of repeating it. Every field and error code is listed in the API reference.

Send from a route without delaying the response

The classic case: your Worker confirms a payment and you want to notify the user's phone. Nobody should wait for the push call to see their confirmation, so you fire it with ctx.waitUntil, which keeps the Worker alive after it responds:

import { sendPush } from './push.js';

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (request.method === 'POST' && url.pathname === '/api/orders/pay') {
      const order = await confirmPayment(request, env); // your logic
      ctx.waitUntil(
        sendPush(env, {
          to: { type: 'users', userIds: [order.userId] },
          title: 'Payment received',
          body: `Your order ${order.number} is confirmed.`,
          url: `https://shop.example/orders/${order.id}`,
          ttl: 3600,
        }, `order:${order.id}:paid`).catch((err) => console.error('push', err.code ?? err)),
      );
      return Response.json({ ok: true });
    }
    return new Response('Not found', { status: 404 });
  },
};

The idempotency key comes from the business event (order:8421:paid), not from a random value. If your payment provider replays the event, the second call with the same key within 24 hours returns the original notification instead of creating another one. And the one-hour ttl keeps the alert from landing the next day on a phone that was switched off, when it no longer helps.

ctx.waitUntil has one limitation: if the call fails, nothing retries it and the user never sees the error. For alerts that can't get lost, use a queue.

Reminders with Cron Triggers and sends from your queue

Cron Triggers for reminders

To remind people about tomorrow's appointments, schedule a Cron Trigger in wrangler.jsonc. Keep in mind the time is in UTC:

{
  "triggers": { "crons": ["0 17 * * *"] }
}

In the scheduled handler you look up who to notify and send. A reminder for many people doesn't need one call per user: to accepts up to 1,000 userIds per request, or a segment by tags.

export default {
  async scheduled(controller, env) {
    const day = new Date(controller.scheduledTime + 86400000).toISOString().slice(0, 10);
    const { results } = await env.DB
      .prepare('SELECT user_id FROM appointments WHERE day = ?')
      .bind(day).all();
    const ids = results.map((row) => row.user_id);
    for (let i = 0; i < ids.length; i += 1000) {
      await sendPush(env, {
        to: { type: 'users', userIds: ids.slice(i, i + 1000) },
        title: 'Your appointment is tomorrow',
        body: "Can't make it? Reschedule from the app.",
        url: 'https://clinic.example/appointments',
        ttl: 43200,
      }, `reminders:${day}:${i}`);
    }
  },
};

The 12-hour ttl means that if a phone is offline, the reminder waits until the early hours at most. Always pick a value that expires before the alert stops making sense.

Your queue for critical events

If your business events already flow through a Cloudflare Queues queue, its consumer is a good place to send from: when a call fails for a temporary reason, you retry just that message.

export default {
  async queue(batch, env) {
    for (const msg of batch.messages) {
      const event = msg.body; // { id, type, userId, … } from your app
      try {
        await sendPush(env, toNotification(event), `event:${event.id}`);
        msg.ack();
      } catch (err) {
        if (err.retryable === false) {
          console.error('push', err.code, event.id); // quota_exceeded, org_suspended…
          msg.ack();
        } else {
          msg.retry({ delaySeconds: 60 });
        }
      }
    }
  },
};

With quota_exceeded or org_suspended there's no point retrying: acknowledge the message, log it and alert your team. With rate_limited, a 5xx or a network failure, msg.retry() puts it back on the queue with a delay, and the same idempotency key guarantees nothing is sent twice if an earlier call did get through.

Receive signed webhooks in a Worker

castly can notify your backend when a device confirms delivery (notification.delivered), when someone clicks (notification.clicked), when a send finishes (notification.completed) or when a subscription stops existing (subscriber.expired). Every call carries a Castly-Signature: t=<unix>,v1=<hex> header, an HMAC-SHA256 of <t>.<body> using your whsec_… secret. Verify it before trusting the content:

async function verify(body, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  if (!parts.t || !/^[0-9a-f]{64}$/.test(parts.v1 ?? '')) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
  );
  const signature = new Uint8Array(parts.v1.match(/../g).map((h) => parseInt(h, 16)));
  return crypto.subtle.verify('HMAC', key, signature, enc.encode(`${parts.t}.${body}`));
}

export default {
  async fetch(request, env, ctx) {
    if (new URL(request.url).pathname === '/webhooks/push') {
      const body = await request.text(); // unparsed: the signature covers it
      const header = request.headers.get('Castly-Signature') ?? '';
      if (!(await verify(body, header, env.CASTLY_WEBHOOK_SECRET))) {
        return new Response('invalid signature', { status: 400 });
      }
      ctx.waitUntil(handleEvent(JSON.parse(body), env)); // your logic, by event.type
      return new Response('ok');
    }
    return new Response('Not found', { status: 404 });
  },
};

Three details: verify before parsing, because the signature covers the exact bytes of the body; use the whole whsec_… secret as the key; and reject signatures older than five minutes so nobody can replay an old call. crypto.subtle.verify compares in constant time, so you don't need a separate comparison. Answer with a 2xx within 10 seconds and leave slow work to ctx.waitUntil or your queue. Webhooks are best effort and aren't retried: to reconcile, call GET /v1/notifications/:id. The full format is in the webhooks docs.

Frequently asked questions

Can I send with the public key from the browser?

No. The pk_live_… key only registers subscribers from the browser. Sending requires the secret key, which should live only in your Worker.

What happens if my Worker sends the same alert twice?

If both calls carry the same Idempotency-Key and are less than 24 hours apart, the API returns the original notification and doesn't create another one. Without that header, every call is a new send.

How many calls can I make per minute?

300 per minute per key. Reaching many devices doesn't take many calls: a send to all your subscribers or to a segment is a single request, and delivery to each device happens in the background.

How do I know whether the alert arrived?

The 202 only confirms the send is queued. For the outcome on each device, call GET /v1/notifications/:id with the id you got back, or listen for the notification.delivered and notification.clicked webhooks.

Do I need a web push library in the Worker?

No. Sending only takes fetch and your secret key: VAPID keys, encryption and delivery to every push service are handled on castly's side.

Put it into practice with castlyWeb and PWA push notifications for developers: an SDK under 3 KB, a REST API and delivery logs for every device. Free up to 10,000 pushes a month.
Create a free account →

Send your first push notification today.

Create a project, add the SDK and send with one API call. Free to start.

Create a free account →