Primeros pasos
Necesitas una cuenta gratuita, un proyecto y su clave pública (pk_live_…), que está en el panel, en Claves de API. Tu web tiene que servirse por HTTPS; localhost también vale para desarrollar.
1. Crea el service worker
Crea /castly-sw.js en la raíz de tu web, de modo que se sirva en https://tu-web.com/castly-sw.js, con esta línea:
importScripts('https://castly.sh/sdk/v1/sw.js');
Si ya tienes un service worker, añade la línea al principio del tuyo y pasa su ruta en swPath (ver El service worker).
2. Carga el SDK y suscribe al usuario
Inicializa el SDK con la clave pública y llama a subscribe() desde un clic: los navegadores solo muestran el permiso tras un gesto del usuario.
<button id="notify" hidden>Avisarme</button>
<script src="https://castly.sh/sdk/v1/castly.js"></script>
<script>
Castly.init({ publicKey: 'pk_live_…' });
const button = document.querySelector('#notify');
button.hidden = !Castly.isSupported();
button.addEventListener('click', async () => {
try {
await Castly.subscribe({ userId: 'user_123', tags: { plan: 'pro' } });
button.hidden = true;
} catch (error) {
console.warn(error.code); // 'denied', 'unsupported'…
}
});
</script>
Con módulos ES:
import { Castly } from 'https://castly.sh/sdk/v1/castly.mjs';
¿Prefieres no programar el botón? Activa el aviso de permiso en el panel: con Castly.init() aparece solo y lo cambias sin desplegar.
3. Envía una notificación
Desde tu servidor, con la clave secreta (sk_live_…) en una variable de entorno:
curl https://castly.sh/v1/notifications \
-H "Authorization: Bearer $CASTLY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": { "type": "users", "userIds": ["user_123"] },
"title": "Tu pedido ha salido",
"body": "Llega hoy entre las 17:00 y las 19:00.",
"url": "https://tienda.example/pedidos/8421"
}'
const response = await fetch('https://castly.sh/v1/notifications', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTLY_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: { type: 'users', userIds: ['user_123'] },
title: 'Tu pedido ha salido',
body: 'Llega hoy entre las 17:00 y las 19:00.',
url: 'https://tienda.example/pedidos/8421',
}),
});
const { id, status } = await response.json(); // 202 → status: 'queued'
La respuesta llega al momento, antes de la entrega:
{ "id": "3f6c2a7e-5b1d-4c8e-9a0f-6d2b7c41e9a5", "status": "queued" }
En el panel, Notificaciones muestra el recorrido de cada dispositivo. Para probar sin escribir código, usa el Playground: suscribe tu propio navegador, que queda marcado como dispositivo de prueba, y envíale un push. Por defecto, el Playground envía a tus dispositivos de prueba.
Conceptos
Organización y proyectos
Cada cuenta (con correo y contraseña, o con Google) tiene una organización, que lleva el plan y la cuota mensual. Por ahora una organización tiene una sola persona: los miembros de equipo todavía no están disponibles. Dentro creas un proyecto por producto, app o web: 1, 5 o 25 según el plan. Cada proyecto tiene sus propias claves VAPID, claves de API, suscriptores, webhook, aviso de permiso y dispositivos de prueba, además de la URL de su web y un icono por defecto: un push sin url abre la web del proyecto y uno sin icon usa ese icono. La cuota de pushes es de la organización y la comparten todos sus proyectos.
Claves de API
| Clave | Dónde | Para qué |
|---|---|---|
pk_live_… | Navegador (pública) | Leer la configuración del cliente y registrar, actualizar o dar de baja suscriptores. |
sk_live_… | Solo en tu servidor (secreta) | Lo mismo, y además enviar notificaciones, listar suscriptores y notificaciones, y exportar. |
De cada clave secreta guardamos solo un resumen SHA-256, así que se muestra una única vez, al crearla. Puedes crear varias por proyecto y revocarlas desde el panel; si una se filtra, revócala y crea otra.
Claves VAPID
VAPID (RFC 8292) es como los servicios push saben quién envía. Cada proyecto genera su par de claves al crearse: la pública la usa el navegador al suscribirse (el SDK la obtiene de GET /v1/client-config) y la privada firma cada push. La privada se guarda cifrada con AES-GCM. El contacto VAPID (un mailto: o una URL https) se configura en el proyecto.
Suscriptores
Un suscriptor es un navegador en un dispositivo que aceptó tus notificaciones. Una persona con el móvil y el portátil son dos suscriptores; si los dos tienen el mismo userId (tu ID de usuario), puedes enviarle a ambos con una sola llamada.
| Estado | Significado |
|---|---|
active | Recibe notificaciones. |
expired | El servicio push respondió 404 o 410: el permiso se retiró o la suscripción caducó. Se marca solo. |
unsubscribed | Se dio de baja con Castly.unsubscribe() o DELETE /v1/subscribers/:id. |
Las etiquetas (tags) son hasta 30 pares clave-valor de texto por suscriptor: las claves cumplen [A-Za-z0-9_-]{1,64} y los valores tienen hasta 200 caracteres. Sirven para segmentar, por ejemplo { "plan": "pro", "lang": "es" }.
Dispositivos de prueba
En el panel puedes marcar suscriptores como dispositivos de prueba, con una etiqueta para reconocerlos (por ejemplo, «iPhone de Ana»). El navegador que suscribes desde el Playground se marca solo. Envíales con to: { "type": "test" } antes de enviar a todo el mundo; el Playground lo hace por defecto. GET /v1/subscribers?test=true los lista.
Notificaciones
Una notificación es un envío a un destino (to). Pasa por queued → processing → completed, o failed si no se pudo procesar. Sus totales (totals) cuentan dispositivos: targeted, sent, delivered, clicked, failed, expired y skipped (los que quedaron por encima de la cuota mensual y no se enviaron).
Logs de entrega
Hay un log por notificación y dispositivo, con su recorrido: sent (el servicio push la aceptó) → delivered (el service worker la recibió) → clicked; o failed y expired, con el código HTTP y el error del servicio push. Se conservan 30 días; los totales diarios del panel se mantienen después.
delivered y clicked los comunica el service worker desde el dispositivo. Si el dispositivo pierde la conexión justo en ese momento, una entrega real puede quedar sin registrar.
Cuotas
Un push es un intento de entrega a un dispositivo. La cuota es por mes natural (UTC) y la comparten todos los proyectos de la organización. Al pasar del 80 % te avisamos por correo; al llegar al 100 %, POST /v1/notifications responde 429 quota_exceeded, y si un envío en curso alcanza el límite, el resto de dispositivos cuenta como skipped. Los envíos vuelven al empezar el mes o al subir de plan.
| Plan | Pushes al mes | Proyectos | Precio |
|---|---|---|---|
| Free | 10.000 | 1 | Gratis |
| Pro | 250.000 | 5 | 15 US$ / mes |
| Scale | 2.000.000 | 25 | 79 US$ / mes |
El SDK
El SDK del navegador pesa menos de 3 KB. Suscribe y da de baja navegadores, gestiona el permiso y asocia cada navegador a tu usuario.
Instalación
Como script clásico, que define window.Castly:
<script src="https://castly.sh/sdk/v1/castly.js"></script>
O como módulo ES:
import { Castly } from 'https://castly.sh/sdk/v1/castly.mjs';
Castly.init(options)
Llámalo una vez por página, antes que cualquier otro método.
| Opción | Tipo | Descripción |
|---|---|---|
publicKey | string | Obligatoria. La clave pública del proyecto, pk_live_…. |
apiUrl | string | Opcional. Otra dirección para la API, por ejemplo para pruebas. |
swPath | string | Opcional. La ruta de tu service worker. Por defecto, /castly-sw.js. |
swScope | string | Opcional. El ámbito con el que se registra el service worker. |
autoPrompt | boolean | Opcional. Con false, el aviso de permiso del panel no aparece solo. Por defecto, true. |
Métodos
| Método | Qué hace |
|---|---|
Castly.isSupported() | true si el navegador admite service workers, la Push API y las notificaciones. |
Castly.needsInstall() | true en iPhone o iPad cuando la web no está abierta desde la pantalla de inicio: hay que instalarla antes de suscribirse (ver iOS y Safari). |
Castly.permission() | El permiso de notificaciones del navegador: default, granted o denied. |
await Castly.subscribe({ userId?, tags? }) | Pide permiso si hace falta, crea la suscripción push y la registra. Llámalo desde un clic. |
await Castly.showPrompt({ force? }) | Muestra el aviso de permiso del panel cuando tú decidas. Resuelve accepted, dismissed o unavailable. |
await Castly.identify(userId, tags?) | Asocia este navegador a tu ID de usuario (por ejemplo, al iniciar sesión) y, si quieres, actualiza sus etiquetas. |
await Castly.setTags(tags) | Actualiza las etiquetas de este navegador. |
await Castly.unsubscribe() | Da de baja este navegador. |
Castly.getSubscriberId() | El ID de suscriptor de este navegador, o nada si no está suscrito. |
Castly.on(event, callback) | Escucha subscribed, unsubscribed y error. |
Errores del SDK
Los métodos que fallan lanzan un CastlyError con una propiedad code:
| code | Significado |
|---|---|
unsupported | El navegador no admite web push. |
denied | La persona bloqueó las notificaciones. Solo puede cambiarlo en los ajustes del navegador. |
not_initialized | Falta llamar a Castly.init(). |
not_subscribed | La operación necesita que este navegador esté suscrito y no lo está. |
network | No se pudo contactar con la API. |
api | La API respondió con un error (ver Errores). |
Ejemplo completo
Castly.init({ publicKey: 'pk_live_…' });
Castly.on('error', (error) => console.warn('push', error.code));
// Desde el botón «Activar notificaciones».
async function enableNotifications(user) {
if (Castly.needsInstall()) return showInstallSteps(); // iPhone/iPad: primero, a la pantalla de inicio
if (!Castly.isSupported()) return;
try {
await Castly.subscribe({ userId: user.id, tags: { plan: user.plan } });
} catch (error) {
if (error.code === 'denied') showHowToUnblock();
else throw error;
}
}
// Al iniciar sesión en un navegador que ya estaba suscrito.
async function onSignIn(user) {
if (await Castly.getSubscriberId()) await Castly.identify(user.id, { plan: user.plan });
}
El service worker
El service worker recibe los push aunque tu web esté cerrada. El de castly muestra la notificación con su título, texto, icono e imagen, abre la url al hacer clic y confirma la entrega y el clic (POST /v1/events) con un token que viaja dentro de cada push.
Si no tienes service worker
importScripts('https://castly.sh/sdk/v1/sw.js');
Sírvelo desde la raíz, en el mismo origen que tu web y por HTTPS, para que su ámbito cubra todas las páginas. El SDK lo registra por ti.
Si ya tienes uno
Un origen no debería tener dos service workers compitiendo por el mismo ámbito. Añade la línea al principio del tuyo:
importScripts('https://castly.sh/sdk/v1/sw.js');
// …el resto de tu service worker (caché, Workbox, etc.)
Y pasa su ruta al SDK:
Castly.init({ publicKey: 'pk_live_…', swPath: '/sw.js' });
importScripts solo existe en los service workers clásicos, no en los registrados con type: "module".
Aviso de permiso
El aviso de permiso es un mensaje tuyo («¿Activamos las notificaciones?») que aparece antes del diálogo del navegador. Lo configuras en el panel, en Aviso de permiso, con una vista previa en vivo. El SDK lo lee de GET /v1/client-config y lo muestra solo después de Castly.init({ publicKey }), así que si lo cambias en el panel tu web cambia sin desplegar nada: en unos 5 minutos, porque la configuración se guarda en caché.
Si el visitante acepta, el SDK lo suscribe; si elige «Ahora no», no se le vuelve a preguntar hasta pasados los días que elijas. Así no gastas el permiso del navegador, que una vez bloqueado es difícil de recuperar. Solo aparece si el navegador admite push y el permiso sigue en default: nunca a quien ya aceptó o bloqueó.
Ajustes
| Campo | En el panel |
|---|---|
enabled | Activado. Si no lo está, prompt llega como null y no aparece. |
position | bottom-right, bottom-left, top o center. |
theme | light, dark o auto (sigue al sistema). |
accent | Color de acento, como #ff6b3d. |
icon | URL https de un icono para el aviso. |
delaySeconds | Segundos en la página antes de mostrarlo (0–300). |
pageViews | Aparece a partir de esa página vista, contadas por navegador (1–20). |
rememberDays | Días de espera tras «Ahora no» (1–365). |
paths | Prefijos de ruta donde puede aparecer, como /blog. Vacío: en todas las páginas. |
texts | Título, texto y botones en español (es) y en inglés (en), según el idioma de la página. |
Controlarlo desde tu código
// Que no aparezca solo…
Castly.init({ publicKey: 'pk_live_…', autoPrompt: false });
// …sino cuando tenga sentido, por ejemplo tras completar un pedido.
async function onOrderCompleted() {
const result = await Castly.showPrompt(); // 'accepted' | 'dismissed' | 'unavailable'
}
// Desde tu propio botón «Notificaciones»: ignora visitas, rutas y «Ahora no».
async function onNotificationsButton() {
await Castly.showPrompt({ force: true });
}
unavailable significa que no se pudo mostrar: el aviso está desactivado, el navegador no admite push, el permiso ya no está en default o, sin force, no se cumplen sus reglas. Si prefieres poner el aviso tú mismo en el HTML, sin depender del panel, usa <castly-optin>.
<castly-optin>
<castly-optin> es la alternativa en HTML al aviso del panel y funciona por su cuenta: un elemento que muestra tu propio aviso previo («¿Te avisamos cuando…?») antes del diálogo de permiso del navegador. Si el visitante acepta, llama a Castly.subscribe() por ti; si lo cierra, no vuelve a aparecer durante remember-days días. Así no gastas el permiso del navegador, que una vez bloqueado es difícil de recuperar.
<script src="https://castly.sh/sdk/v1/castly.js"></script>
<script src="https://castly.sh/sdk/v1/optin.js"></script>
<script>
Castly.init({ publicKey: 'pk_live_…' });
</script>
<castly-optin
title="¿Te avisamos de las novedades?"
text="Solo cuando publiquemos algo importante."
position="bottom-right"
delay="5000"
tags='{"source":"blog"}'
></castly-optin>
También hay versión ESM: https://castly.sh/sdk/v1/optin.mjs.
Atributos
| Atributo | Descripción |
|---|---|
title | Título del aviso. |
text | Texto del aviso. |
accept | Texto del botón de aceptar. |
dismiss | Texto del botón de cerrar. |
position | bottom-right, bottom-left, top, center o inline (se muestra donde pongas el elemento). |
theme | light, dark o auto (sigue al sistema). |
delay | Milisegundos de espera antes de mostrarlo. |
user-id | Tu ID de usuario para la suscripción. |
tags | Etiquetas en JSON, como {"plan":"pro"}. |
icon | URL https de un icono para el aviso. |
accent | Color de acento, como #ff6b3d. |
remember-days | Días que se recuerda que el visitante lo cerró. |
Sin title, text, accept ni dismiss, los textos salen en el idioma de la página (el atributo lang de <html>).
Estilo y eventos
castly-optin {
--castly-accent: #ff6b3d;
--castly-radius: 12px;
--castly-font: system-ui, sans-serif;
}
const optin = document.querySelector('castly-optin');
optin.addEventListener('castly:optin-accepted', () => console.log('subscribed'));
optin.addEventListener('castly:optin-dismissed', () => console.log('dismissed'));
API REST
Base: https://castly.sh/v1. Peticiones y respuestas en JSON, autenticadas con Authorization: Bearer <clave>.
- Las fechas son marcas de tiempo Unix en segundos y los IDs, UUID.
- Las listas se paginan con
limitycursor: pasa elnextCursorde una respuesta para pedir la página siguiente; en la última esnull. POST /v1/notificationsadmite la cabeceraIdempotency-Key: repetir la petición con la misma clave durante 24 horas no crea otra notificación.- Cada clave admite 300 peticiones por minuto (ver Límites).
Endpoints
| Método y ruta | Clave | Para qué |
|---|---|---|
GET /v1/client-config | pk · sk | Configuración del navegador |
POST /v1/subscribers | pk · sk | Registrar un suscriptor |
PATCH /v1/subscribers/:id | pk · sk | Cambiar usuario y etiquetas |
DELETE /v1/subscribers/:id | pk · sk | Dar de baja |
GET /v1/subscribers | sk | Listar suscriptores |
POST /v1/notifications | sk | Enviar una notificación |
GET /v1/notifications | sk | Listar notificaciones |
GET /v1/notifications/:id | sk | Una notificación con sus logs |
POST /v1/events | — | Entregas y clics (service worker) |
GET /v1/project | sk | Datos del proyecto |
GET /v1/project/export | sk | Exportar claves y suscriptores |
Configuración del cliente
GET/v1/client-configpk o sk
Lo que el SDK necesita: el ID del proyecto, su clave VAPID pública y el aviso de permiso, o null si está desactivado. La respuesta se guarda en caché unos 5 minutos.
{
"projectId": "c41d7e2a-8f3b-4a69-9e0d-5b7a1c2f6e83",
"vapidPublicKey": "BOr3x…",
"prompt": {
"enabled": true,
"position": "bottom-right",
"theme": "auto",
"accent": "#ff6b3d",
"icon": null,
"delaySeconds": 5,
"pageViews": 1,
"rememberDays": 7,
"paths": [],
"texts": {
"es": {
"title": "¿Activamos las notificaciones?",
"text": "Te avisaremos de lo importante. Puedes desactivarlas cuando quieras.",
"accept": "Activar",
"dismiss": "Ahora no"
},
"en": {
"title": "Turn on notifications?",
"text": "We'll let you know about what matters. You can turn them off anytime.",
"accept": "Turn on",
"dismiss": "Not now"
}
}
}
}
Registrar un suscriptor
POST/v1/subscriberspk o sk
El SDK lo hace por ti. El cuerpo es lo que da PushSubscription.toJSON(), más tu ID de usuario y etiquetas opcionales. Responde 201 si el endpoint es nuevo y 200 si ya estaba registrado.
{
"endpoint": "https://fcm.googleapis.com/fcm/send/dK3…",
"keys": { "p256dh": "BNcR…", "auth": "tBHI…" },
"userId": "user_123",
"tags": { "plan": "pro" }
}
{ "id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "status": "active" }
Actualizar un suscriptor
PATCH/v1/subscribers/:idpk o sk
Cambia el userId (o lo quita con null) y las etiquetas (tags). Hace falta al menos uno de los dos.
{ "userId": "user_123", "tags": { "plan": "scale" } }
{ "id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "userId": "user_123", "tags": { "plan": "scale" } }
Dar de baja un suscriptor
DELETE/v1/subscribers/:idpk o sk
{ "id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "status": "unsubscribed" }
Listar suscriptores
GET/v1/subscriberssolo sk
| Parámetro | Descripción |
|---|---|
status | active, expired, unsubscribed |
userId | Solo los de ese ID de usuario. |
test | true: solo los dispositivos de prueba. |
tag | clave:valor, por ejemplo plan:pro. |
limit | Hasta 100; 50 por defecto. |
cursor | El nextCursor de la página anterior. |
curl "https://castly.sh/v1/subscribers?status=active&tag=plan:pro&limit=2" \
-H "Authorization: Bearer $CASTLY_SECRET_KEY"
{
"data": [
{
"id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34",
"externalUserId": "user_123",
"tags": { "plan": "pro" },
"browser": "Chrome",
"os": "Android",
"pushService": "fcm.googleapis.com",
"status": "active",
"test": false,
"label": null,
"lastActiveAt": 1763049612,
"createdAt": 1762444800
}
],
"nextCursor": null
}
Enviar una notificación
POST/v1/notificationssolo sk
| Campo | Tipo | Descripción |
|---|---|---|
to | object | Obligatorio. El destino (abajo). |
title | string | Obligatorio. Hasta 120 caracteres. |
body | string | Hasta 400 caracteres. |
url | string | Se abre al hacer clic. Si falta, se abre la URL de la web del proyecto. |
icon | string | URL https del icono. Si falta, se usa el icono por defecto del proyecto. |
image | string | URL https de una imagen grande (no todos los navegadores la muestran). |
data | object | JSON libre que viaja con la notificación. |
ttl | integer | Segundos que el servicio push la guarda si el dispositivo está desconectado. Por defecto 86400 (24 h), máximo 2419200 (28 días). |
urgency | string | very-low, low, normal (por defecto) o high. |
Destinos (to):
| to | A quién llega |
|---|---|
{ "type": "all" } | Todos los suscriptores activos del proyecto. |
{ "type": "users", "userIds": [...] } | Todos los dispositivos de esos IDs de usuario (hasta 1.000 IDs). |
{ "type": "subscribers", "subscriberIds": [...] } | Esos suscriptores (hasta 1.000). |
{ "type": "segment", "tags": { "plan": "pro" } } | Quien tenga todas esas etiquetas a la vez. |
{ "type": "test" } | Los dispositivos de prueba del proyecto. |
curl https://castly.sh/v1/notifications \
-H "Authorization: Bearer $CASTLY_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8421-shipped" \
-d '{
"to": { "type": "segment", "tags": { "plan": "pro" } },
"title": "Tu pedido ha salido",
"body": "Llega hoy entre las 17:00 y las 19:00.",
"url": "https://tienda.example/pedidos/8421",
"ttl": 3600,
"urgency": "high"
}'
const response = await fetch('https://castly.sh/v1/notifications', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTLY_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-8421-shipped',
},
body: JSON.stringify({
to: { type: 'segment', tags: { plan: 'pro' } },
title: 'Tu pedido ha salido',
body: 'Llega hoy entre las 17:00 y las 19:00.',
url: 'https://tienda.example/pedidos/8421',
ttl: 3600,
urgency: 'high',
}),
});
if (response.status !== 202) {
const { error } = await response.json();
throw new Error(`${error.code}: ${error.message}`);
}
{ "id": "3f6c2a7e-5b1d-4c8e-9a0f-6d2b7c41e9a5", "status": "queued" }
Errores propios de este endpoint: 403 org_suspended si la organización está suspendida y 429 quota_exceeded si la cuota del mes está agotada.
Los servicios push aceptan unos 4 KB por mensaje cifrado: title, body, url, icon, image y data tienen que caber juntos.
Listar notificaciones
GET/v1/notificationssolo sk
Las más recientes primero, con limit y cursor.
{
"data": [
{
"id": "3f6c2a7e-5b1d-4c8e-9a0f-6d2b7c41e9a5",
"projectId": "c41d7e2a-8f3b-4a69-9e0d-5b7a1c2f6e83",
"title": "Tu pedido ha salido",
"body": "Llega hoy entre las 17:00 y las 19:00.",
"url": "https://tienda.example/pedidos/8421",
"icon": null,
"image": null,
"data": {},
"target": { "type": "users", "userIds": ["user_123"] },
"status": "completed",
"source": "api",
"totals": {
"targeted": 2, "sent": 2, "delivered": 2, "clicked": 1,
"failed": 0, "expired": 0, "skipped": 0
},
"error": null,
"createdAt": 1763049600,
"completedAt": 1763049601
}
],
"nextCursor": "eyJ0IjoxNzYzMDQ5NjAwfQ"
}
Consultar una notificación
GET/v1/notifications/:idsolo sk
La notificación con sus logs: los 500 más recientes, uno por dispositivo.
{
"id": "3f6c2a7e-5b1d-4c8e-9a0f-6d2b7c41e9a5",
"status": "completed",
"totals": { "targeted": 2, "sent": 2, "delivered": 2, "clicked": 1, "failed": 0, "expired": 0, "skipped": 0 },
"…": "…",
"logs": [
{
"id": "e7a1c9d4-2b5f-4f30-8c6e-1d9b3a7f5e20",
"subscriberId": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34",
"externalUserId": "user_123",
"browser": "Chrome",
"os": "Android",
"status": "clicked",
"httpStatus": 201,
"error": null,
"sentAt": 1763049600,
"deliveredAt": 1763049601,
"clickedAt": 1763049608
}
]
}
Eventos del service worker
POST/v1/eventssin clave
Lo llama el service worker de castly para confirmar delivered y clicked, con el token que viaja dentro de cada push. No necesitas llamarlo tú. Responde 202.
{ "type": "clicked", "notificationId": "3f6c2a7e-5b1d-4c8e-9a0f-6d2b7c41e9a5", "subscriberId": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "token": "…" }
Proyecto
GET/v1/projectsolo sk
{
"id": "c41d7e2a-8f3b-4a69-9e0d-5b7a1c2f6e83",
"name": "Tienda",
"vapidPublicKey": "BOr3x…",
"vapidSubject": "mailto:soporte@tienda.example",
"webhookUrl": "https://tienda.example/webhooks/push"
}
Exportar
GET/v1/project/exportsolo sk
Todo lo necesario para seguir enviando con cualquier librería de web push. Ver Exportar e irte.
Webhooks
Configura una URL https en el panel, en Webhooks. El panel te da el secreto de firma (whsec_…): guárdalo como variable de entorno en tu servidor. Cada evento llega como un POST con JSON.
Eventos
| type | Cuándo |
|---|---|
notification.delivered | Un dispositivo confirmó la entrega. |
notification.clicked | Alguien hizo clic en la notificación. |
notification.completed | Terminaron de procesarse todos los lotes de una notificación. |
subscriber.expired | El servicio push respondió 404 o 410 y el suscriptor pasó a expired. |
Formato
POST /webhooks/push HTTP/1.1
Content-Type: application/json
Castly-Event: notification.clicked
Castly-Delivery: …
Castly-Signature: t=1763049608,v1=5f2b…c9e1
{
"id": "5d8f2c1a-6e4b-4b97-a3d0-7c2e9f1b4a68",
"type": "notification.clicked",
"createdAt": 1763049608,
"data": { … }
}
data depende del tipo de evento. Castly-Event repite el tipo y Castly-Delivery identifica la llamada.
Verificar la firma
Castly-Signature tiene la forma t=<unix>,v1=<hex>. Calcula un HMAC-SHA256 en hexadecimal con tu secreto sobre <t>.<cuerpo> (el cuerpo tal como llega, sin parsear) y compáralo con v1 en tiempo constante. Rechaza también las firmas con un t de hace más de 5 minutos, para evitar que alguien repita una llamada antigua.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.CASTLY_WEBHOOK_SECRET; // whsec_…
function verify(rawBody, header) {
const parts = Object.fromEntries(header.split(',').map((part) => part.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 expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest();
return crypto.timingSafeEqual(expected, Buffer.from(parts.v1, 'hex'));
}
// El cuerpo sin parsear: la firma se calcula sobre él.
app.post('/webhooks/push', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verify(rawBody, req.get('Castly-Signature') ?? '')) return res.sendStatus(400);
const event = JSON.parse(rawBody);
if (event.type === 'subscriber.expired') {
// …
}
res.sendStatus(200); // en menos de 10 s
});
export default {
async fetch(request, env) {
const body = await request.text();
const header = request.headers.get('Castly-Signature') ?? '';
if (!(await verify(body, header, env.CASTLY_WEBHOOK_SECRET))) {
return new Response('invalid signature', { status: 400 });
}
const event = JSON.parse(body);
// …event.type
return new Response('ok');
},
};
async function verify(body, header, secret) {
const parts = Object.fromEntries(header.split(',').map((part) => part.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 encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const signature = new Uint8Array(parts.v1.match(/../g).map((byte) => parseInt(byte, 16)));
// verify() compara en tiempo constante.
return crypto.subtle.verify('HMAC', key, signature, encoder.encode(`${parts.t}.${body}`));
}
Entrega
Responde con un 2xx en menos de 10 segundos; si tienes que hacer algo lento, encólalo y responde antes. En la v1 los webhooks son de mejor esfuerzo y no se reintentan: para conciliar, consulta GET /v1/notifications/:id. En el panel ves cada llamada con su respuesta.
Errores
Los errores responden con un código HTTP y este cuerpo. Usa code en tu lógica; message es para personas y puede cambiar.
{ "error": { "code": "quota_exceeded", "message": "Has agotado los pushes de este mes." } }
| HTTP | Cuándo |
|---|---|
| 400 | El cuerpo o los parámetros no son válidos: falta un campo, el título es demasiado largo, el destino está vacío… |
| 401 · 403 | Falta la clave, no es válida, está revocada o no sirve para esa ruta (una pk_live_… donde hace falta una sk_live_…). |
| 403 | org_suspended: la organización está suspendida. |
| 404 | El recurso no existe en este proyecto. |
| 429 | rate_limited: más de 300 peticiones por minuto con la misma clave. quota_exceeded: la cuota del mes está agotada. |
| 5xx | Un error nuestro. Reintenta con espera creciente; con Idempotency-Key no duplicarás envíos. |
Los errores del SDK del navegador se describen en Errores del SDK.
Límites
| Qué | Límite |
|---|---|
| Peticiones a la API | 300 por minuto y clave (429 rate_limited) |
| Pushes al mes | Según el plan: 10.000 · 250.000 · 2.000.000 (429 quota_exceeded) |
title · body | 120 · 400 caracteres |
| Mensaje push | Unos 4 KB, límite de los servicios push |
userIds · subscriberIds | 1.000 por petición |
ttl | Hasta 28 días (2419200 s); 24 h por defecto |
| Etiquetas por suscriptor | 30; claves de 64 y valores de 200 caracteres |
| Página de una lista | limit hasta 100 (50 por defecto) |
Logs en GET /v1/notifications/:id | Los 500 más recientes |
Idempotency-Key | 24 horas |
| Logs de entrega | 30 días |
Ante un 429 rate_limited, espera y reintenta con espera exponencial. Para llegar a muchos dispositivos no hacen falta muchas peticiones: un envío a all o a un segmento es una sola llamada.
Exportar e irte
Tus claves y tus suscriptores son tuyos. GET /v1/project/export (o Exportar, en el panel) devuelve todo lo necesario para seguir enviando sin castly, sin que tus usuarios tengan que volver a suscribirse.
curl https://castly.sh/v1/project/export \
-H "Authorization: Bearer $CASTLY_SECRET_KEY" \
-o export.json
{
"project": { "id": "c41d7e2a-8f3b-4a69-9e0d-5b7a1c2f6e83", "name": "Tienda", "…": "…" },
"vapid": {
"publicKey": "BOr3x…",
"privateKey": "…",
"subject": "mailto:soporte@tienda.example"
},
"subscribers": [
{
"id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34",
"endpoint": "https://fcm.googleapis.com/fcm/send/dK3…",
"keys": { "p256dh": "BNcR…", "auth": "tBHI…" },
"userId": "user_123",
"tags": { "plan": "pro" },
"status": "active",
"createdAt": 1762444800
}
],
"exportedAt": 1763049600
}
Enviar con otra librería
Por ejemplo, con la librería web-push de Node:
import { readFile } from 'node:fs/promises';
import webpush from 'web-push';
const data = JSON.parse(await readFile('export.json', 'utf8'));
webpush.setVapidDetails(data.vapid.subject, data.vapid.publicKey, data.vapid.privateKey);
for (const subscriber of data.subscribers) {
if (subscriber.status !== 'active') continue;
await webpush.sendNotification(
{ endpoint: subscriber.endpoint, keys: subscriber.keys },
JSON.stringify({ title: 'Hola' }),
);
}
El export incluye la clave privada VAPID: guárdalo como un secreto. Al irte, cambia también tu service worker, porque el de castly entiende el formato de castly: el tuyo tendrá que mostrar las notificaciones con el formato que envíes.
iOS y Safari
iPhone y iPad
- iOS y iPadOS 16.4 o posterior.
- La web tiene que estar añadida a la pantalla de inicio (Compartir → Añadir a pantalla de inicio) y abierta desde ese icono. En una pestaña de Safari no existe la Push API.
- Necesitas un manifest web con
display: standalone(ofullscreen) enlazado desde tus páginas. - El permiso se pide con un gesto del usuario dentro de la web instalada.
{
"name": "Tienda Ejemplo",
"short_name": "Tienda",
"start_url": "/",
"display": "standalone",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
<link rel="manifest" href="/manifest.webmanifest">
if (Castly.needsInstall()) {
showInstallSteps(); // iPhone/iPad en una pestaña: explica cómo añadir la web a la pantalla de inicio
} else if (Castly.isSupported()) {
showNotifyButton();
}
Safari en macOS
Safari 16 o posterior, en macOS 13 Ventura o posterior, admite web push en pestañas normales, como Chrome o Firefox. Igual que en el resto de navegadores, pide el permiso desde un clic.
Navegadores
| Navegador | Web push |
|---|---|
| Chrome, Edge, Opera, Samsung Internet y otros Chromium | Sí, en escritorio y Android |
| Firefox | Sí, en escritorio y Android |
| Safari en macOS | Sí, desde Safari 16 |
| iPhone y iPad | Sí, desde iOS 16.4, con la web en la pantalla de inicio |
Apple no admite pushes silenciosos: cada push debe mostrar una notificación, y el service worker de castly siempre la muestra.