Documentación

Todo para enviar web push con castly: el SDK del navegador, el service worker, la API REST y los webhooks. Base de la API: https://castly.sh/v1.

En esta página

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:

castly-sw.js
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.

index.html
<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:

js
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
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"
  }'
Node.js
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:

HTTP 202
{ "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

ClaveDóndePara 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.

EstadoSignificado
activeRecibe notificaciones.
expiredEl servicio push respondió 404 o 410: el permiso se retiró o la suscripción caducó. Se marca solo.
unsubscribedSe 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.

PlanPushes al mesProyectosPrecio
Free10.0001Gratis
Pro250.000515 US$ / mes
Scale2.000.0002579 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:

html
<script src="https://castly.sh/sdk/v1/castly.js"></script>

O como módulo ES:

js
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ónTipoDescripción
publicKeystringObligatoria. La clave pública del proyecto, pk_live_….
apiUrlstringOpcional. Otra dirección para la API, por ejemplo para pruebas.
swPathstringOpcional. La ruta de tu service worker. Por defecto, /castly-sw.js.
swScopestringOpcional. El ámbito con el que se registra el service worker.
autoPromptbooleanOpcional. Con false, el aviso de permiso del panel no aparece solo. Por defecto, true.

Métodos

MétodoQué 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:

codeSignificado
unsupportedEl navegador no admite web push.
deniedLa persona bloqueó las notificaciones. Solo puede cambiarlo en los ajustes del navegador.
not_initializedFalta llamar a Castly.init().
not_subscribedLa operación necesita que este navegador esté suscrito y no lo está.
networkNo se pudo contactar con la API.
apiLa API respondió con un error (ver Errores).

Ejemplo completo

app.js
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

castly-sw.js
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:

sw.js
importScripts('https://castly.sh/sdk/v1/sw.js');

// …el resto de tu service worker (caché, Workbox, etc.)

Y pasa su ruta al SDK:

js
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

CampoEn el panel
enabledActivado. Si no lo está, prompt llega como null y no aparece.
positionbottom-right, bottom-left, top o center.
themelight, dark o auto (sigue al sistema).
accentColor de acento, como #ff6b3d.
iconURL https de un icono para el aviso.
delaySecondsSegundos en la página antes de mostrarlo (0–300).
pageViewsAparece a partir de esa página vista, contadas por navegador (1–20).
rememberDaysDías de espera tras «Ahora no» (1–365).
pathsPrefijos de ruta donde puede aparecer, como /blog. Vacío: en todas las páginas.
textsTí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

js
// 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.

html
<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

AtributoDescripción
titleTítulo del aviso.
textTexto del aviso.
acceptTexto del botón de aceptar.
dismissTexto del botón de cerrar.
positionbottom-right, bottom-left, top, center o inline (se muestra donde pongas el elemento).
themelight, dark o auto (sigue al sistema).
delayMilisegundos de espera antes de mostrarlo.
user-idTu ID de usuario para la suscripción.
tagsEtiquetas en JSON, como {"plan":"pro"}.
iconURL https de un icono para el aviso.
accentColor de acento, como #ff6b3d.
remember-daysDí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

styles.css
castly-optin {
  --castly-accent: #ff6b3d;
  --castly-radius: 12px;
  --castly-font: system-ui, sans-serif;
}
js
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 limit y cursor: pasa el nextCursor de una respuesta para pedir la página siguiente; en la última es null.
  • POST /v1/notifications admite la cabecera Idempotency-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 rutaClavePara qué
GET /v1/client-configpk · skConfiguración del navegador
POST /v1/subscriberspk · skRegistrar un suscriptor
PATCH /v1/subscribers/:idpk · skCambiar usuario y etiquetas
DELETE /v1/subscribers/:idpk · skDar de baja
GET /v1/subscribersskListar suscriptores
POST /v1/notificationsskEnviar una notificación
GET /v1/notificationsskListar notificaciones
GET /v1/notifications/:idskUna notificación con sus logs
POST /v1/events—Entregas y clics (service worker)
GET /v1/projectskDatos del proyecto
GET /v1/project/exportskExportar 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.

Respuesta
{
  "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.

Petición
{
  "endpoint": "https://fcm.googleapis.com/fcm/send/dK3…",
  "keys": { "p256dh": "BNcR…", "auth": "tBHI…" },
  "userId": "user_123",
  "tags": { "plan": "pro" }
}
Respuesta
{ "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.

Petición
{ "userId": "user_123", "tags": { "plan": "scale" } }
Respuesta
{ "id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "userId": "user_123", "tags": { "plan": "scale" } }

Dar de baja un suscriptor

DELETE/v1/subscribers/:idpk o sk

Respuesta
{ "id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "status": "unsubscribed" }

Listar suscriptores

GET/v1/subscriberssolo sk

ParámetroDescripción
statusactive, expired, unsubscribed
userIdSolo los de ese ID de usuario.
testtrue: solo los dispositivos de prueba.
tagclave:valor, por ejemplo plan:pro.
limitHasta 100; 50 por defecto.
cursorEl nextCursor de la página anterior.
bash
curl "https://castly.sh/v1/subscribers?status=active&tag=plan:pro&limit=2" \
  -H "Authorization: Bearer $CASTLY_SECRET_KEY"
Respuesta
{
  "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

CampoTipoDescripción
toobjectObligatorio. El destino (abajo).
titlestringObligatorio. Hasta 120 caracteres.
bodystringHasta 400 caracteres.
urlstringSe abre al hacer clic. Si falta, se abre la URL de la web del proyecto.
iconstringURL https del icono. Si falta, se usa el icono por defecto del proyecto.
imagestringURL https de una imagen grande (no todos los navegadores la muestran).
dataobjectJSON libre que viaja con la notificación.
ttlintegerSegundos que el servicio push la guarda si el dispositivo está desconectado. Por defecto 86400 (24 h), máximo 2419200 (28 días).
urgencystringvery-low, low, normal (por defecto) o high.

Destinos (to):

toA 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
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"
  }'
Node.js
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}`);
}
HTTP 202
{ "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.

Respuesta
{
  "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.

Respuesta
{
  "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.

Petición
{ "type": "clicked", "notificationId": "3f6c2a7e-5b1d-4c8e-9a0f-6d2b7c41e9a5", "subscriberId": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "token": "…" }

Proyecto

GET/v1/projectsolo sk

Respuesta
{
  "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

typeCuándo
notification.deliveredUn dispositivo confirmó la entrega.
notification.clickedAlguien hizo clic en la notificación.
notification.completedTerminaron de procesarse todos los lotes de una notificación.
subscriber.expiredEl servicio push respondió 404 o 410 y el suscriptor pasó a expired.

Formato

http
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.

server.js
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
});
worker.js
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.

json
{ "error": { "code": "quota_exceeded", "message": "Has agotado los pushes de este mes." } }
HTTPCuándo
400El cuerpo o los parámetros no son válidos: falta un campo, el título es demasiado largo, el destino está vacío…
401 · 403Falta la clave, no es válida, está revocada o no sirve para esa ruta (una pk_live_… donde hace falta una sk_live_…).
403org_suspended: la organización está suspendida.
404El recurso no existe en este proyecto.
429rate_limited: más de 300 peticiones por minuto con la misma clave. quota_exceeded: la cuota del mes está agotada.
5xxUn 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 API300 por minuto y clave (429 rate_limited)
Pushes al mesSegún el plan: 10.000 · 250.000 · 2.000.000 (429 quota_exceeded)
title · body120 · 400 caracteres
Mensaje pushUnos 4 KB, límite de los servicios push
userIds · subscriberIds1.000 por petición
ttlHasta 28 días (2419200 s); 24 h por defecto
Etiquetas por suscriptor30; claves de 64 y valores de 200 caracteres
Página de una listalimit hasta 100 (50 por defecto)
Logs en GET /v1/notifications/:idLos 500 más recientes
Idempotency-Key24 horas
Logs de entrega30 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.

bash
curl https://castly.sh/v1/project/export \
  -H "Authorization: Bearer $CASTLY_SECRET_KEY" \
  -o export.json
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:

send.mjs
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 (o fullscreen) enlazado desde tus páginas.
  • El permiso se pide con un gesto del usuario dentro de la web instalada.
manifest.webmanifest
{
  "name": "Tienda Ejemplo",
  "short_name": "Tienda",
  "start_url": "/",
  "display": "standalone",
  "icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
html
<link rel="manifest" href="/manifest.webmanifest">
js
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

NavegadorWeb push
Chrome, Edge, Opera, Samsung Internet y otros ChromiumSí, en escritorio y Android
FirefoxSí, en escritorio y Android
Safari en macOSSí, desde Safari 16
iPhone y iPadSí, 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.

¿Algo no está claro o falta? Escríbenos a hola@castly.sh.