Blog · Ingeniería
Notificaciones push desde Cloudflare Workers
Envía notificaciones push desde tu app en Cloudflare Workers: la clave como secret, un helper con fetch, rutas, Cron Triggers y colas sin bloquear, y webhooks firmados.
Si tu app corre en Cloudflare Workers, enviar una notificación push cuando pasa algo en tu negocio (un pago, un mensaje nuevo, una cita mañana) se reduce a una llamada fetch a una API de envío con tu clave secreta. En esta guía conectas los eventos de tu Worker con castly: la clave como secret, un helper reutilizable, envíos desde rutas, Cron Triggers y tu propia cola sin hacer esperar al usuario, y la recepción de webhooks firmados.
En resumen:
- Guarda la clave secreta
sk_live_…conwrangler secret put; la públicapk_live_…es la única que llega al navegador.- Un helper de pocas líneas envuelve
POST /v1/notificationsy distingue202,429 rate_limited,429 quota_exceededy403 org_suspended.- Usa
ctx.waitUntilo tu propia cola para que el envío no retrase la respuesta, y un Cron Trigger para los recordatorios.- Deriva el
Idempotency-Keydel identificador del evento de negocio para que un reintento no duplique el aviso.- Verifica la firma de los webhooks con
crypto.subtle.verifyantes de confiar en ellos.
Lo que necesita el frontend
El Worker solo envía: la suscripción ocurre en el navegador. En tu frontend cargas el SDK, lo inicializas con la clave pública y llamas a Castly.subscribe() desde un clic, con el mismo identificador de usuario que usa tu backend:
<script src="https://castly.sh/sdk/v1/castly.js"></script>
<script>
Castly.init({ publicKey: 'pk_live_…' });
document.querySelector('#activar-avisos').addEventListener('click', () =>
Castly.subscribe({ userId: currentUser.id })
);
</script>Además, tu web sirve /castly-sw.js desde la raíz, con una sola línea: importScripts('https://castly.sh/sdk/v1/sw.js');. Si despliegas el frontend con los Static Assets de Workers o con Pages, basta con dejar ese archivo en la carpeta pública de tu proyecto para que se sirva tal cual. Comprueba que el enrutado de tu SPA no responda a esa ruta con el index.html, porque el navegador rechazaría el registro. Lo explicamos en el service worker en las notificaciones push.
La clave secreta, como secret del Worker
La clave sk_live_… permite enviar a todos tus suscriptores, así que trátala como una contraseña. En Workers, su sitio es un secret:
npx wrangler secret put CASTLY_SECRET_KEY
npx wrangler secret put CASTLY_WEBHOOK_SECRETLos secrets llegan a tu código como propiedades de env, igual que las variables, pero no aparecen en wrangler.jsonc ni en texto plano en el panel de Cloudflare. En local, ponlos en .dev.vars y añade ese archivo a .gitignore. Usa una clave por entorno: la de un proyecto de pruebas en staging y la de producción solo en producción, para que un test nunca llegue a usuarios reales. Si una clave se filtra, revócala en el panel y crea otra.
Un helper para enviar con fetch
Centraliza la llamada en una función para que todas las rutas compartan cabeceras, manejo de errores e idempotencia:
// 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 y los 5xx son temporales; quota_exceeded y org_suspended, no.
throw new PushError(res.status, code, code === 'rate_limited' || res.status >= 500);
}Qué significa cada respuesta y qué hacer con ella:
| Respuesta | Significado | Qué hacer |
|---|---|---|
202 | Aceptado: { id, status: "queued" }. La entrega a cada dispositivo sigue en segundo plano. | Guarda el id si quieres consultar el resultado. |
429 rate_limited | Más de 300 peticiones por minuto con la misma clave. | Reintenta más tarde, con espera creciente. |
429 quota_exceeded | Se agotaron los pushes del mes de tu plan. | No reintentes: avisa a tu equipo. Los envíos vuelven con el mes nuevo o al subir de plan. |
403 org_suspended | La organización está suspendida. | No reintentes; revisa tu correo y el panel. |
5xx | Error temporal del servicio. | Reintenta con espera creciente. |
Distinguir los dos 429 es clave: uno se arregla esperando y el otro no. Si solo miras el código HTTP, acabarás reintentando sin fin un envío que no puede salir. Lo mismo vale para los errores de validación, como un título de más de 120 caracteres: corrige el aviso en lugar de repetirlo.
Enviar desde una ruta sin retrasar la respuesta
El caso típico: tu Worker confirma un pago y quieres avisar al usuario en su móvil. Nadie debería esperar a la llamada de push para ver su confirmación, así que la lanzas con ctx.waitUntil, que mantiene vivo el Worker después de responder:
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); // tu lógica
ctx.waitUntil(
sendPush(env, {
to: { type: 'users', userIds: [order.userId] },
title: 'Pago recibido',
body: `Tu pedido ${order.number} está confirmado.`,
url: `https://tienda.example/pedidos/${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 });
},
};La clave de idempotencia sale del evento de negocio (order:8421:paid), no de un valor aleatorio. Si tu pasarela de pago repite el evento, la segunda llamada con la misma clave, dentro de 24 horas, devuelve la notificación original en lugar de crear otra. Y el ttl de una hora evita que el aviso llegue al día siguiente a un móvil que estuvo apagado, cuando ya no aporta nada. Lo tratamos a fondo en idempotencia al enviar notificaciones.
ctx.waitUntil tiene un límite: si la llamada falla, nadie la reintenta y el usuario no ve el error. Para avisos que no pueden perderse, usa una cola.
Recordatorios con Cron Triggers y envíos desde tu cola
Cron Triggers para recordatorios
Para recordar las citas del día siguiente, programa un Cron Trigger en wrangler.jsonc. Ten en cuenta que la hora va en UTC:
{
"triggers": { "crons": ["0 17 * * *"] }
}En el manejador scheduled buscas a quién avisar y envías. Un recordatorio para muchas personas no exige una llamada por usuario: to acepta hasta 1.000 userIds por petición, o un segmento por etiquetas.
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: 'Tu cita es mañana',
body: 'Si no puedes venir, cámbiala desde la app.',
url: 'https://clinica.example/citas',
ttl: 43200,
}, `reminders:${day}:${i}`);
}
},
};El ttl de 12 horas hace que, si un móvil está desconectado, el recordatorio espere como mucho hasta la madrugada. Elige siempre un valor que caduque antes de que el aviso pierda sentido.
Tu cola para los eventos críticos
Si tus eventos de negocio ya pasan por una cola de Cloudflare Queues, su consumidor es buen sitio para enviar: si una llamada falla por un motivo temporal, reintentas solo ese mensaje.
export default {
async queue(batch, env) {
for (const msg of batch.messages) {
const event = msg.body; // { id, type, userId, … } de tu 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 });
}
}
}
},
};Con quota_exceeded u org_suspended no tiene sentido reintentar: confirmas el mensaje, lo registras y avisas a tu equipo. Con rate_limited, un 5xx o un fallo de red, msg.retry() lo devuelve a la cola con un retraso, y la misma clave de idempotencia garantiza que no se envíe dos veces si una llamada anterior sí llegó.
Recibir los webhooks firmados en un Worker
castly puede avisar a tu backend cuando un dispositivo confirma la entrega (notification.delivered), cuando alguien hace clic (notification.clicked), cuando termina un envío (notification.completed) o cuando una suscripción deja de existir (subscriber.expired). Cada llamada lleva la cabecera Castly-Signature: t=<unix>,v1=<hex>, un HMAC-SHA256 de <t>.<cuerpo> con tu secreto whsec_…. Verifícala antes de confiar en el contenido:
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(); // sin parsear: la firma se calcula sobre él
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)); // tu lógica, según event.type
return new Response('ok');
}
return new Response('Not found', { status: 404 });
},
};Tres detalles: verifica antes de parsear, porque la firma cubre los bytes exactos del cuerpo; usa el secreto whsec_… completo como clave; y rechaza firmas de hace más de cinco minutos para que nadie repita una llamada antigua. crypto.subtle.verify compara en tiempo constante, así que no necesitas otra comparación. Responde con un 2xx en menos de 10 segundos y deja el trabajo lento a ctx.waitUntil o a tu cola. Los webhooks son de mejor esfuerzo y no se reintentan: para conciliar, consulta GET /v1/notifications/:id. Tienes el formato completo en la documentación de webhooks y el porqué de cada comprobación en verificar webhooks con HMAC.
Preguntas frecuentes
¿Puedo enviar con la clave pública desde el navegador?
No. La clave pk_live_… solo sirve para registrar suscriptores desde el navegador. Enviar exige la secreta, que debe vivir únicamente en tu Worker.
¿Qué pasa si mi Worker envía el mismo aviso dos veces?
Si las dos llamadas llevan la misma Idempotency-Key y están separadas por menos de 24 horas, la API devuelve la notificación original y no se crea otra. Sin esa cabecera, cada llamada es un envío nuevo.
¿Cuántas llamadas puedo hacer por minuto?
300 por minuto y clave. Para llegar a muchos dispositivos no hacen falta muchas llamadas: un envío a todos tus suscriptores o a un segmento es una sola petición, y la entrega a cada dispositivo ocurre en segundo plano.
¿Cómo sé si el aviso llegó?
El 202 solo confirma que el envío está en cola. Para el resultado de cada dispositivo, consulta GET /v1/notifications/:id con el id recibido o escucha los webhooks notification.delivered y notification.clicked.
¿Necesito una librería de web push en el Worker?
No. Para enviar solo necesitas fetch y tu clave secreta: las claves VAPID, el cifrado y la entrega a cada servicio push quedan del lado de castly.