Documentation

Everything to send web push with castly: the browser SDK, the service worker, the REST API and webhooks. API base URL: https://castly.sh/v1.

On this page

Quickstart

You need a free account, a project and its public key (pk_live_…), which you find in the dashboard under API keys. Your site must be served over HTTPS; localhost works too while developing.

1. Create the service worker

Create /castly-sw.js at the root of your site, so it is served at https://your-site.com/castly-sw.js, with this line:

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

If you already have a service worker, add the line at the top of yours and pass its path as swPath (see The service worker).

2. Load the SDK and subscribe the user

Initialize the SDK with the public key and call subscribe() from a click: browsers only show the permission prompt after a user gesture.

index.html
<button id="notify" hidden>Notify me</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>

With ES modules:

js
import { Castly } from 'https://castly.sh/sdk/v1/castly.mjs';

Rather not build the button? Turn on the opt-in prompt in the dashboard: with Castly.init() it shows up by itself and you change it without a deploy.

3. Send a notification

From your server, with the secret key (sk_live_…) in an environment variable:

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": "Your order has shipped",
    "body": "Arriving today between 5 and 7 pm.",
    "url": "https://shop.example/orders/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: 'Your order has shipped',
    body: 'Arriving today between 5 and 7 pm.',
    url: 'https://shop.example/orders/8421',
  }),
});
const { id, status } = await response.json(); // 202 → status: 'queued'

The response comes right away, before delivery:

HTTP 202
{ "id": "3f6c2a7e-5b1d-4c8e-9a0f-6d2b7c41e9a5", "status": "queued" }

In the dashboard, Notifications shows the trail of every device. To try it without writing code, use the Playground: subscribe your own browser, which is marked as a test device, and send it a push. By default, the Playground sends to your test devices.

Concepts

Organization and projects

Every account (email and password, or Google) has one organization, which holds the plan and the monthly quota. For now an organization has a single person: team members are not available yet. Inside it you create one project per product, app or website: 1, 5 or 25 depending on the plan. Each project has its own VAPID keys, API keys, subscribers, webhook, opt-in prompt and test devices, plus its site URL and a default icon: a push without url opens the project's site and one without icon uses that icon. The push quota belongs to the organization and is shared by all its projects.

API keys

KeyWhereWhat for
pk_live_…Browser (public)Reading the client config and registering, updating or unsubscribing subscribers.
sk_live_…Your server only (secret)The same, plus sending notifications, listing subscribers and notifications, and exporting.

We only keep a SHA-256 hash of each secret key, so it is shown once, when you create it. You can create several per project and revoke them from the dashboard; if one leaks, revoke it and create another.

VAPID keys

VAPID (RFC 8292) is how push services know who is sending. Each project generates its key pair when it is created: the browser uses the public key to subscribe (the SDK gets it from GET /v1/client-config) and the private key signs every push. The private key is stored encrypted with AES-GCM. The VAPID contact (a mailto: or an https URL) is set in the project.

Subscribers

A subscriber is one browser on one device that accepted your notifications. A person with a phone and a laptop is two subscribers; if both have the same userId (your user ID), you can reach both with one call.

StatusMeaning
activeReceives notifications.
expiredThe push service answered 404 or 410: permission was withdrawn or the subscription expired. Marked automatically.
unsubscribedUnsubscribed with Castly.unsubscribe() or DELETE /v1/subscribers/:id.

Tags are up to 30 text key-value pairs per subscriber: keys match [A-Za-z0-9_-]{1,64} and values are up to 200 characters. They are what segments use, for example { "plan": "pro", "lang": "en" }.

Test devices

In the dashboard you can mark subscribers as test devices, with a label to recognize them (for example, "Ana's iPhone"). The browser you subscribe from the Playground is marked automatically. Send to them with to: { "type": "test" } before sending to everyone; the Playground does so by default. GET /v1/subscribers?test=true lists them.

Notifications

A notification is one send to a target (to). It goes through queued → processing → completed, or failed if it could not be processed. Its totals count devices: targeted, sent, delivered, clicked, failed, expired and skipped (those over the monthly quota, never sent).

Delivery logs

There is one log per notification and device, with its trail: sent (the push service accepted it) → delivered (the service worker received it) → clicked; or failed and expired, with the HTTP status and error from the push service. Logs are kept for 30 days; the daily totals in the dashboard stay after that.

delivered and clicked are reported by the service worker from the device. If the device loses its connection at that moment, a real delivery may go unrecorded.

Quotas

A push is one delivery attempt to one device. The quota is per calendar month (UTC) and shared by every project in the organization. When you pass 80% we email you; at 100%, POST /v1/notifications answers 429 quota_exceeded, and if a send in progress reaches the limit, the remaining devices count as skipped. Sending resumes when the month starts or when you upgrade.

PlanPushes per monthProjectsPrice
Free10,0001Free
Pro250,0005US$15 / month
Scale2,000,00025US$79 / month

The SDK

The browser SDK weighs under 3 KB. It subscribes and unsubscribes browsers, handles permission and links each browser to your user.

Installation

As a classic script, which defines window.Castly:

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

Or as an ES module:

js
import { Castly } from 'https://castly.sh/sdk/v1/castly.mjs';

Castly.init(options)

Call it once per page, before any other method.

OptionTypeDescription
publicKeystringRequired. The project's public key, pk_live_….
apiUrlstringOptional. Another address for the API, for example for testing.
swPathstringOptional. The path of your service worker. Defaults to /castly-sw.js.
swScopestringOptional. The scope the service worker is registered with.
autoPromptbooleanOptional. With false, the dashboard opt-in prompt does not show by itself. Defaults to true.

Methods

MethodWhat it does
Castly.isSupported()true if the browser supports service workers, the Push API and notifications.
Castly.needsInstall()true on iPhone or iPad when the site is not opened from the home screen: it must be installed before subscribing (see iOS and Safari).
Castly.permission()The browser's notification permission: default, granted or denied.
await Castly.subscribe({ userId?, tags? })Asks for permission if needed, creates the push subscription and registers it. Call it from a click.
await Castly.showPrompt({ force? })Shows the dashboard opt-in prompt when you decide. Resolves accepted, dismissed or unavailable.
await Castly.identify(userId, tags?)Links this browser to your user ID (for example, on sign-in) and optionally updates its tags.
await Castly.setTags(tags)Updates this browser's tags.
await Castly.unsubscribe()Unsubscribes this browser.
Castly.getSubscriberId()This browser's subscriber ID, or nothing if it is not subscribed.
Castly.on(event, callback)Listens to subscribed, unsubscribed and error.

SDK errors

Methods that fail throw a CastlyError with a code property:

codeMeaning
unsupportedThe browser does not support web push.
deniedThe person blocked notifications. Only they can change it, in the browser settings.
not_initializedCastly.init() has not been called.
not_subscribedThe operation needs this browser to be subscribed and it is not.
networkThe API could not be reached.
apiThe API answered with an error (see Errors).

Full example

app.js
Castly.init({ publicKey: 'pk_live_…' });
Castly.on('error', (error) => console.warn('push', error.code));

// From the "Turn on notifications" button.
async function enableNotifications(user) {
  if (Castly.needsInstall()) return showInstallSteps(); // iPhone/iPad: add to home screen first
  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;
  }
}

// On sign-in, in a browser that was already subscribed.
async function onSignIn(user) {
  if (await Castly.getSubscriberId()) await Castly.identify(user.id, { plan: user.plan });
}

The service worker

The service worker receives pushes even when your site is closed. castly's shows the notification with its title, body, icon and image, opens the url on click and confirms delivery and the click (POST /v1/events) with a token that travels inside each push.

If you have no service worker

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

Serve it from the root, on the same origin as your site and over HTTPS, so its scope covers every page. The SDK registers it for you.

If you already have one

An origin should not have two service workers competing for the same scope. Add the line at the top of yours:

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

// …the rest of your service worker (caching, Workbox, etc.)

And pass its path to the SDK:

js
Castly.init({ publicKey: 'pk_live_…', swPath: '/sw.js' });

importScripts only exists in classic service workers, not in those registered with type: "module".

Opt-in prompt

The opt-in prompt is your own message ("Turn on notifications?") shown before the browser's dialog. You set it up in the dashboard, under Opt-in prompt, with a live preview. The SDK reads it from GET /v1/client-config and shows it by itself after Castly.init({ publicKey }), so when you change it in the dashboard your site changes without a deploy: within about 5 minutes, because the config is cached.

If the visitor accepts, the SDK subscribes them; if they choose "Not now", they are not asked again for the number of days you choose. That way you do not spend the browser permission, which is hard to get back once blocked. It only appears when the browser supports push and permission is still default: never to someone who already accepted or blocked.

Settings

FieldIn the dashboard
enabledEnabled. When it is not, prompt comes as null and nothing shows.
positionbottom-right, bottom-left, top or center.
themelight, dark or auto (follows the system).
accentAccent color, such as #ff6b3d.
iconhttps URL of an icon for the prompt.
delaySecondsSeconds on the page before it shows (0–300).
pageViewsIt appears from that page view on, counted per browser (1–20).
rememberDaysDays to wait after "Not now" (1–365).
pathsPath prefixes where it may appear, such as /blog. Empty: every page.
textsTitle, text and buttons in Spanish (es) and English (en), by the language of the page.

Control it from your code

js
// Do not show it by itself…
Castly.init({ publicKey: 'pk_live_…', autoPrompt: false });

// …but when it makes sense, for example after an order is completed.
async function onOrderCompleted() {
  const result = await Castly.showPrompt(); // 'accepted' | 'dismissed' | 'unavailable'
}

// From your own "Notifications" button: ignores page views, paths and "Not now".
async function onNotificationsButton() {
  await Castly.showPrompt({ force: true });
}

unavailable means it could not be shown: the prompt is disabled, the browser does not support push, permission is no longer default or, without force, its rules are not met. If you would rather place the prompt in your HTML yourself, without the dashboard, use <castly-optin>.

<castly-optin>

<castly-optin> is the HTML alternative to the dashboard prompt and works on its own: an element that shows your own soft prompt ("Can we let you know when…?") before the browser's permission dialog. If the visitor accepts, it calls Castly.subscribe() for you; if they close it, it stays hidden for remember-days days. That way you do not spend the browser permission, which is hard to get back once blocked.

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="Want to hear about new releases?"
  text="Only when we publish something important."
  position="bottom-right"
  delay="5000"
  tags='{"source":"blog"}'
></castly-optin>

There is an ESM version too: https://castly.sh/sdk/v1/optin.mjs.

Attributes

AttributeDescription
titlePrompt title.
textPrompt text.
acceptAccept button label.
dismissDismiss button label.
positionbottom-right, bottom-left, top, center or inline (shown where you place the element).
themelight, dark or auto (follows the system).
delayMilliseconds to wait before showing it.
user-idYour user ID for the subscription.
tagsTags as JSON, such as {"plan":"pro"}.
iconhttps URL of an icon for the prompt.
accentAccent color, such as #ff6b3d.
remember-daysDays to remember that the visitor dismissed it.

Without title, text, accept and dismiss, the texts follow the language of the page (the lang attribute of <html>).

Styling and events

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'));

REST API

Base URL: https://castly.sh/v1. JSON requests and responses, authenticated with Authorization: Bearer <key>.

  • Timestamps are Unix seconds and IDs are UUIDs.
  • Lists are paginated with limit and cursor: pass the nextCursor of a response to get the next page; it is null on the last one.
  • POST /v1/notifications accepts an Idempotency-Key header: repeating the request with the same key within 24 hours does not create another notification.
  • Each key allows 300 requests per minute (see Limits).

Endpoints

Method and pathKeyWhat for
GET /v1/client-configpk · skBrowser config
POST /v1/subscriberspk · skRegister a subscriber
PATCH /v1/subscribers/:idpk · skChange user and tags
DELETE /v1/subscribers/:idpk · skUnsubscribe
GET /v1/subscribersskList subscribers
POST /v1/notificationsskSend a notification
GET /v1/notificationsskList notifications
GET /v1/notifications/:idskOne notification with its logs
POST /v1/events—Deliveries and clicks (service worker)
GET /v1/projectskProject details
GET /v1/project/exportskExport keys and subscribers

Client config

GET/v1/client-configpk or sk

What the SDK needs: the project ID, its public VAPID key and the opt-in prompt, or null when it is disabled. The response is cached for about 5 minutes.

Response
{
  "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"
      }
    }
  }
}

Register a subscriber

POST/v1/subscriberspk or sk

The SDK does it for you. The body is what PushSubscription.toJSON() returns, plus your optional user ID and tags. It answers 201 for a new endpoint and 200 if it was already registered.

Request
{
  "endpoint": "https://fcm.googleapis.com/fcm/send/dK3…",
  "keys": { "p256dh": "BNcR…", "auth": "tBHI…" },
  "userId": "user_123",
  "tags": { "plan": "pro" }
}
Response
{ "id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "status": "active" }

Update a subscriber

PATCH/v1/subscribers/:idpk or sk

Changes the userId (or removes it with null) and the tags. At least one of them is required.

Request
{ "userId": "user_123", "tags": { "plan": "scale" } }
Response
{ "id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "userId": "user_123", "tags": { "plan": "scale" } }

Unsubscribe a subscriber

DELETE/v1/subscribers/:idpk or sk

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

List subscribers

GET/v1/subscriberssk only

ParameterDescription
statusactive, expired, unsubscribed
userIdOnly those with that user ID.
testtrue: only test devices.
tagkey:value, for example plan:pro.
limitUp to 100; 50 by default.
cursorThe nextCursor of the previous page.
bash
curl "https://castly.sh/v1/subscribers?status=active&tag=plan:pro&limit=2" \
  -H "Authorization: Bearer $CASTLY_SECRET_KEY"
Response
{
  "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
}

Send a notification

POST/v1/notificationssk only

FieldTypeDescription
toobjectRequired. The target (below).
titlestringRequired. Up to 120 characters.
bodystringUp to 400 characters.
urlstringOpened on click. If missing, the project's site URL opens.
iconstringhttps URL of the icon. If missing, the project's default icon is used.
imagestringhttps URL of a large image (not every browser shows it).
dataobjectFree-form JSON that travels with the notification.
ttlintegerSeconds the push service keeps it while the device is offline. Default 86400 (24 h), max 2419200 (28 days).
urgencystringvery-low, low, normal (default) or high.

Targets (to):

toWho gets it
{ "type": "all" }Every active subscriber of the project.
{ "type": "users", "userIds": [...] }Every device of those user IDs (up to 1,000 IDs).
{ "type": "subscribers", "subscriberIds": [...] }Those subscribers (up to 1,000).
{ "type": "segment", "tags": { "plan": "pro" } }Whoever has all of those tags at once.
{ "type": "test" }The project's test devices.
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": "Your order has shipped",
    "body": "Arriving today between 5 and 7 pm.",
    "url": "https://shop.example/orders/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: 'Your order has shipped',
    body: 'Arriving today between 5 and 7 pm.',
    url: 'https://shop.example/orders/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" }

Errors specific to this endpoint: 403 org_suspended if the organization is suspended and 429 quota_exceeded if the monthly quota is used up.

Push services accept about 4 KB per encrypted message: title, body, url, icon, image and data must fit together.

List notifications

GET/v1/notificationssk only

Newest first, with limit and cursor.

Response
{
  "data": [
    {
      "id": "3f6c2a7e-5b1d-4c8e-9a0f-6d2b7c41e9a5",
      "projectId": "c41d7e2a-8f3b-4a69-9e0d-5b7a1c2f6e83",
      "title": "Your order has shipped",
      "body": "Arriving today between 5 and 7 pm.",
      "url": "https://shop.example/orders/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"
}

Retrieve a notification

GET/v1/notifications/:idsk only

The notification with its logs: the 500 newest, one per device.

Response
{
  "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
    }
  ]
}

Service worker events

POST/v1/eventsno key

castly's service worker calls it to confirm delivered and clicked, with the token that travels inside each push. You do not need to call it yourself. It answers 202.

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

Project

GET/v1/projectsk only

Response
{
  "id": "c41d7e2a-8f3b-4a69-9e0d-5b7a1c2f6e83",
  "name": "Shop",
  "vapidPublicKey": "BOr3x…",
  "vapidSubject": "mailto:support@shop.example",
  "webhookUrl": "https://shop.example/webhooks/push"
}

Export

GET/v1/project/exportsk only

Everything you need to keep sending with any web push library. See Export and leave.

Webhooks

Set an https URL in the dashboard, under Webhooks. The dashboard gives you the signing secret (whsec_…): keep it as an environment variable on your server. Each event arrives as a JSON POST.

Events

typeWhen
notification.deliveredA device confirmed delivery.
notification.clickedSomeone clicked the notification.
notification.completedEvery batch of a notification has been processed.
subscriber.expiredThe push service answered 404 or 410 and the subscriber became expired.

Format

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 depends on the event type. Castly-Event repeats the type and Castly-Delivery identifies the call.

Verify the signature

Castly-Signature looks like t=<unix>,v1=<hex>. Compute a hex HMAC-SHA256 with your secret over <t>.<body> (the raw body, before parsing) and compare it with v1 in constant time. Also reject signatures whose t is more than 5 minutes old, so nobody can replay an old call.

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'));
}

// The raw body: the signature is computed over it.
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); // within 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() compares in constant time.
  return crypto.subtle.verify('HMAC', key, signature, encoder.encode(`${parts.t}.${body}`));
}

Delivery

Answer with a 2xx within 10 seconds; if you need to do something slow, queue it and answer first. In v1 webhooks are best-effort and not retried: to reconcile, call GET /v1/notifications/:id. The dashboard shows every call with its response.

Errors

Errors come with an HTTP status and this body. Use code in your logic; message is for humans and may change.

json
{ "error": { "code": "quota_exceeded", "message": "You have used up this month's pushes." } }
HTTPWhen
400The body or the parameters are not valid: a missing field, a title that is too long, an empty target…
401 · 403The key is missing, invalid, revoked or not allowed on that route (a pk_live_… where a sk_live_… is needed).
403org_suspended: the organization is suspended.
404The resource does not exist in this project.
429rate_limited: over 300 requests per minute with the same key. quota_exceeded: the monthly quota is used up.
5xxOur error. Retry with backoff; with Idempotency-Key you will not send twice.

Errors from the browser SDK are described in SDK errors.

Limits

WhatLimit
API requests300 per minute per key (429 rate_limited)
Pushes per monthBy plan: 10,000 · 250,000 · 2,000,000 (429 quota_exceeded)
title · body120 · 400 characters
Push messageAbout 4 KB, the push services limit
userIds · subscriberIds1,000 per request
ttlUp to 28 days (2419200 s); 24 h by default
Tags per subscriber30; keys up to 64 and values up to 200 characters
List pagelimit up to 100 (50 by default)
Logs in GET /v1/notifications/:idThe 500 newest
Idempotency-Key24 hours
Delivery logs30 days

On a 429 rate_limited, wait and retry with exponential backoff. Reaching many devices does not take many requests: a send to all or to a segment is a single call.

Export and leave

Your keys and your subscribers are yours. GET /v1/project/export (or Export, in the dashboard) returns everything you need to keep sending without castly, without your users subscribing again.

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": "Shop", "…": "…" },
  "vapid": {
    "publicKey": "BOr3x…",
    "privateKey": "…",
    "subject": "mailto:support@shop.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
}

Send with another library

For example, with the web-push library for 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: 'Hello' }),
  );
}

The export includes the private VAPID key: store it like a secret. When you leave, change your service worker too, because castly's understands castly's format: yours will need to show notifications in the format you send.

iOS and Safari

iPhone and iPad

  • iOS and iPadOS 16.4 or later.
  • The site must be added to the home screen (Share → Add to Home Screen) and opened from that icon. A Safari tab has no Push API.
  • You need a web app manifest with display: standalone (or fullscreen) linked from your pages.
  • Permission is requested with a user gesture inside the installed site.
manifest.webmanifest
{
  "name": "Example Shop",
  "short_name": "Shop",
  "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 in a tab: explain how to add the site to the home screen
} else if (Castly.isSupported()) {
  showNotifyButton();
}

Safari on macOS

Safari 16 or later, on macOS 13 Ventura or later, supports web push in regular tabs, like Chrome or Firefox. As in every other browser, ask for permission from a click.

Browsers

BrowserWeb push
Chrome, Edge, Opera, Samsung Internet and other ChromiumYes, on desktop and Android
FirefoxYes, on desktop and Android
Safari on macOSYes, since Safari 16
iPhone and iPadYes, since iOS 16.4, with the site on the home screen

Apple does not allow silent pushes: every push must show a notification, and castly's service worker always shows one.

Something unclear or missing? Write to us at hola@castly.sh.