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:
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.
<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:
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 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"
}'
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:
{ "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
| Key | Where | What 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.
| Status | Meaning |
|---|---|
active | Receives notifications. |
expired | The push service answered 404 or 410: permission was withdrawn or the subscription expired. Marked automatically. |
unsubscribed | Unsubscribed 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.
| Plan | Pushes per month | Projects | Price |
|---|---|---|---|
| Free | 10,000 | 1 | Free |
| Pro | 250,000 | 5 | US$15 / month |
| Scale | 2,000,000 | 25 | US$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:
<script src="https://castly.sh/sdk/v1/castly.js"></script>
Or as an ES module:
import { Castly } from 'https://castly.sh/sdk/v1/castly.mjs';
Castly.init(options)
Call it once per page, before any other method.
| Option | Type | Description |
|---|---|---|
publicKey | string | Required. The project's public key, pk_live_…. |
apiUrl | string | Optional. Another address for the API, for example for testing. |
swPath | string | Optional. The path of your service worker. Defaults to /castly-sw.js. |
swScope | string | Optional. The scope the service worker is registered with. |
autoPrompt | boolean | Optional. With false, the dashboard opt-in prompt does not show by itself. Defaults to true. |
Methods
| Method | What 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:
| code | Meaning |
|---|---|
unsupported | The browser does not support web push. |
denied | The person blocked notifications. Only they can change it, in the browser settings. |
not_initialized | Castly.init() has not been called. |
not_subscribed | The operation needs this browser to be subscribed and it is not. |
network | The API could not be reached. |
api | The API answered with an error (see Errors). |
Full example
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
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:
importScripts('https://castly.sh/sdk/v1/sw.js');
// …the rest of your service worker (caching, Workbox, etc.)
And pass its path to the SDK:
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
| Field | In the dashboard |
|---|---|
enabled | Enabled. When it is not, prompt comes as null and nothing shows. |
position | bottom-right, bottom-left, top or center. |
theme | light, dark or auto (follows the system). |
accent | Accent color, such as #ff6b3d. |
icon | https URL of an icon for the prompt. |
delaySeconds | Seconds on the page before it shows (0–300). |
pageViews | It appears from that page view on, counted per browser (1–20). |
rememberDays | Days to wait after "Not now" (1–365). |
paths | Path prefixes where it may appear, such as /blog. Empty: every page. |
texts | Title, text and buttons in Spanish (es) and English (en), by the language of the page. |
Control it from your code
// 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.
<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
| Attribute | Description |
|---|---|
title | Prompt title. |
text | Prompt text. |
accept | Accept button label. |
dismiss | Dismiss button label. |
position | bottom-right, bottom-left, top, center or inline (shown where you place the element). |
theme | light, dark or auto (follows the system). |
delay | Milliseconds to wait before showing it. |
user-id | Your user ID for the subscription. |
tags | Tags as JSON, such as {"plan":"pro"}. |
icon | https URL of an icon for the prompt. |
accent | Accent color, such as #ff6b3d. |
remember-days | Days 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
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'));
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
limitandcursor: pass thenextCursorof a response to get the next page; it isnullon the last one. POST /v1/notificationsaccepts anIdempotency-Keyheader: 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 path | Key | What for |
|---|---|---|
GET /v1/client-config | pk · sk | Browser config |
POST /v1/subscribers | pk · sk | Register a subscriber |
PATCH /v1/subscribers/:id | pk · sk | Change user and tags |
DELETE /v1/subscribers/:id | pk · sk | Unsubscribe |
GET /v1/subscribers | sk | List subscribers |
POST /v1/notifications | sk | Send a notification |
GET /v1/notifications | sk | List notifications |
GET /v1/notifications/:id | sk | One notification with its logs |
POST /v1/events | — | Deliveries and clicks (service worker) |
GET /v1/project | sk | Project details |
GET /v1/project/export | sk | Export 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.
{
"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.
{
"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" }
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.
{ "userId": "user_123", "tags": { "plan": "scale" } }
{ "id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "userId": "user_123", "tags": { "plan": "scale" } }
Unsubscribe a subscriber
DELETE/v1/subscribers/:idpk or sk
{ "id": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "status": "unsubscribed" }
List subscribers
GET/v1/subscriberssk only
| Parameter | Description |
|---|---|
status | active, expired, unsubscribed |
userId | Only those with that user ID. |
test | true: only test devices. |
tag | key:value, for example plan:pro. |
limit | Up to 100; 50 by default. |
cursor | The nextCursor of the previous page. |
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
}
Send a notification
POST/v1/notificationssk only
| Field | Type | Description |
|---|---|---|
to | object | Required. The target (below). |
title | string | Required. Up to 120 characters. |
body | string | Up to 400 characters. |
url | string | Opened on click. If missing, the project's site URL opens. |
icon | string | https URL of the icon. If missing, the project's default icon is used. |
image | string | https URL of a large image (not every browser shows it). |
data | object | Free-form JSON that travels with the notification. |
ttl | integer | Seconds the push service keeps it while the device is offline. Default 86400 (24 h), max 2419200 (28 days). |
urgency | string | very-low, low, normal (default) or high. |
Targets (to):
| to | Who 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 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"
}'
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}`);
}
{ "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.
{
"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.
{
"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.
{ "type": "clicked", "notificationId": "3f6c2a7e-5b1d-4c8e-9a0f-6d2b7c41e9a5", "subscriberId": "9b2e4d1c-7a3f-4e85-b0c6-2f1d8e5a7c34", "token": "…" }
Project
GET/v1/projectsk only
{
"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
| type | When |
|---|---|
notification.delivered | A device confirmed delivery. |
notification.clicked | Someone clicked the notification. |
notification.completed | Every batch of a notification has been processed. |
subscriber.expired | The push service answered 404 or 410 and the subscriber became expired. |
Format
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.
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
});
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.
{ "error": { "code": "quota_exceeded", "message": "You have used up this month's pushes." } }
| HTTP | When |
|---|---|
| 400 | The body or the parameters are not valid: a missing field, a title that is too long, an empty target… |
| 401 · 403 | The key is missing, invalid, revoked or not allowed on that route (a pk_live_… where a sk_live_… is needed). |
| 403 | org_suspended: the organization is suspended. |
| 404 | The resource does not exist in this project. |
| 429 | rate_limited: over 300 requests per minute with the same key. quota_exceeded: the monthly quota is used up. |
| 5xx | Our error. Retry with backoff; with Idempotency-Key you will not send twice. |
Errors from the browser SDK are described in SDK errors.
Limits
| What | Limit |
|---|---|
| API requests | 300 per minute per key (429 rate_limited) |
| Pushes per month | By plan: 10,000 · 250,000 · 2,000,000 (429 quota_exceeded) |
title · body | 120 · 400 characters |
| Push message | About 4 KB, the push services limit |
userIds · subscriberIds | 1,000 per request |
ttl | Up to 28 days (2419200 s); 24 h by default |
| Tags per subscriber | 30; keys up to 64 and values up to 200 characters |
| List page | limit up to 100 (50 by default) |
Logs in GET /v1/notifications/:id | The 500 newest |
Idempotency-Key | 24 hours |
| Delivery logs | 30 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.
curl https://castly.sh/v1/project/export \
-H "Authorization: Bearer $CASTLY_SECRET_KEY" \
-o 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:
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(orfullscreen) linked from your pages. - Permission is requested with a user gesture inside the installed site.
{
"name": "Example Shop",
"short_name": "Shop",
"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 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
| Browser | Web push |
|---|---|
| Chrome, Edge, Opera, Samsung Internet and other Chromium | Yes, on desktop and Android |
| Firefox | Yes, on desktop and Android |
| Safari on macOS | Yes, since Safari 16 |
| iPhone and iPad | Yes, 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.