Quickstart
relaybell adds web push notifications to any website. Drop in a snippet, and you can collect subscribers and send notifications β with automation, targeting, and analytics β in minutes.
Grab your Project ID from your dashboard (Install page). On WordPress, use the plugin instead β it does steps 1 and 2 for you.
1. Add the snippet to every page, just before </body>:
<script src="https://relaybell.com/relaybell-sdk.js"></script>
<script>
RelayBell.init({ projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com" });
</script>
2. Host the service worker. Download sw.js and upload it to the root of your site, so it opens at https://yoursite.com/sw.js. Browsers only run a service worker from your own domain, so push can't work without this file. (Can't put it at the root? See Installing the SDK.)
3. Send. relaybell now shows your opt-in prompt, subscribes visitors and stores them. To send from your server, create an API key under Settings β Developer β API keys and POST to the REST API (see Sending) β or send from the dashboard.
Installing the SDK
The SDK needs a service worker on your own domain. The default swPath is /sw.js β host our sw.js there, or put it somewhere else on the same domain and pass that path as swPath (for example "/folder/sw.js"). The WordPress plugin serves its own service worker, so there's no file to upload. Shopify doesn't let you upload this file β see Shopify.
apiBase is the relaybell address the SDK talks to. If you leave it out, the SDK uses the address it was loaded from β https://relaybell.com for the snippet above β so the snippet works either way.
Browser SDK β init & options
RelayBell.init({
projectId: "YOUR_PROJECT_ID", // required
apiBase: "https://relaybell.com", // optional: defaults to where relaybell-sdk.js was loaded from
swPath: "/sw.js", // service-worker path on your domain
externalId: "user_123", // link this browser to your own user id
userHash: "9f2cβ¦", // your server's signature of externalId (see Identity verification)
tags: { plan: "pro" }, // custom attributes for segmentation
requireConsent: false, // true = wait for grantConsent() (CMP integration)
policyVersion: "2026-01" // recorded with consent for your audit trail
});
Browser SDK β methods
| Method | What it does |
|---|---|
init(opts) | Registers the SW, shows the opt-in prompt, subscribes. |
subscribeNow() | Triggers the browser permission prompt (call from a click). Resolves { supported, subscribed, permission?, reason? } β subscribed is true only once relaybell has stored the subscription; supported is false where the browser can't do push (e.g. iOS Safari in a normal tab). Safe to call before init() has finished: it waits up to ~15 s, then resolves with reason: "not_ready". |
isSubscribed() | Resolves true if this browser has a push subscription for your site. Never prompts β use it to decide whether to show a βNotify meβ button or to track() interest. |
identify( | Links this browser to your own user id after the page has loaded (for example once the visitor signs in). userHash is optional unless you require identity verification. |
unsubscribe() | Opts the visitor out and suppresses future sends. |
isSupported() | { supported, browser, os, needsInstall } β feature detection. |
setTags({...}) | Update custom attributes for segmentation. |
setTopics( | Save which topics the subscriber wants. |
showPreferences() | Opens a preference-center modal of your topics. |
showInbox() | Opens a modal of your recent notifications. |
track(event, props) | Send a behavioral event (fires automations + attribution). |
trackConversion( | Record a purchase or conversion with revenue. |
grantConsent() / revokeConsent() | For cookie-consent (CMP) gating when requireConsent is set. |
// Behavioral tracking β powers cart abandonment, product alerts, attribution
RelayBell.track("cart.updated", { productId: "1234", product: "Blue Shoes", price: 49, url: location.href });
RelayBell.trackConversion(79.99, { orderId: "A123" });
Opt-in prompts
Prompt style, copy, trigger and colors are configured per project β in the dashboard under Audience β Subscribe prompt (with a live preview), or with PUT /api/v1/prompt. Styles: slidein (default), bar, modal, native. Triggers: delay, load, click, scroll, exit (desktop only). Add a privacy policy link in Settings β Privacy (it's the privacyUrl setting, not a prompt field) to show it on the prompt. Set a prompt b variant to A/B-test opt-in copy. Visitors who click the βnoβ button (or click outside a modal) aren't asked again for 7 days.
curl -X PUT https://relaybell.com/api/v1/prompt \
-H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
-d '{ "prompt": { "type": "slidein", "trigger": "delay", "delaySeconds": 5,
"title": "Stay in the loop", "message": "Get notified about offers.",
"allowText": "Allow", "denyText": "No thanks",
"position": "bottom-right", "accentColor": "#0B7C93" } }'
| Field | Values |
|---|---|
enabled | false = never ask automatically (visitors can still subscribe via your own button + subscribeNow()). |
type | slidein Β· bar Β· modal Β· native (browser prompt only β Safari and Firefox only allow it inside a click, so unless the trigger is click their visitors get the slidein box instead). |
trigger | delay Β· load Β· scroll Β· exit Β· click. |
delaySeconds, scrollPercent | For delay (0β600) and scroll (1β100). |
clickSelector | CSS selector of your button, for the click trigger. |
title, message, allowText, denyText | Prompt copy (longer text is cut to 120 / 300 / 40 / 40 characters). |
position | bottom-right Β· bottom-left Β· top-right Β· top-left (slide-in), top Β· bottom (bar). |
accentColor | Button color β must be a hex color like #0B7C93; anything else is rejected with 400 invalid_color. |
iosInstallHint | false hides the iPhone/iPad βAdd to Home Screenβ how-to. |
b | Version B for an A/B test: { title, message, allowText } (empty fields fall back to version A). |
PUT /api/v1/prompt accepts the fields listed above only β anything else is dropped β and it replaces the whole prompt, so send every field you want to keep (GET /api/v1/prompt returns the current one).
Preview: open any page of your site with ?rb_preview_prompt=1 (e.g. https://yoursite.com/?rb_preview_prompt=1). The SDK removes the parameter from the address bar and shows your saved prompt (version A, in a browser that supports push) straight away β ignoring enabled, the 7-day dismissal, the current permission and the trigger. Clicking βnoβ in preview doesn't record a dismissal. For type: "native" it shows the slide-in card instead, since the browser's own prompt can't be previewed. The dashboard's See it on my website link does exactly this.
REST API β authentication
All server-side calls use a Bearer API key (starts with rb_live_):
curl https://relaybell.com/api/v1/stats \
-H "Authorization: Bearer rb_live_xxxxx"
Create keys in the dashboard under Settings β Developers β API keys. A key is shown once, when you create it; relaybell stores only a hash. Keep keys on your server β never put one in code that runs in a visitor's browser (the browser SDK needs only your project ID).
API keys & scopes
Each key has a name, a list of scopes (what it may do) and an optional expiry date. Pick Full access, Send only, or any mix of the scopes below. Keys made before scopes existed have full access.
| Scope | Allows |
|---|---|
full | Everything below (and it is the only scope that can list keys with GET /api/v1/keys). |
send | POST /api/v1/notifications (sends and drafts), PATCH / DELETE /notifications/:id, POST /notifications/:id/send and /cancel. |
events:write | POST /api/v1/events. |
subscribers:read | GET /api/v1/subscribers/export, /subscribers/recent, /subscribers/:externalId/data and /profile, GET /api/v1/events. |
subscribers:write | POST /api/v1/subscribers/import, DELETE /api/v1/subscribers/:externalId. |
segments:write | Create, change and delete segments. |
settings:write | PUT /api/v1/settings, /prompt, /welcome, /logo, topics, POST /reports/test. |
webhooks:write | Create, change and delete webhooks. |
Reading your website's stats, reports, settings and notifications works with any valid key (a key without full or settings:write doesn't get the Shopify signing secret back from GET /api/v1/settings). A call the key isn't allowed to make answers 403 { "error": "insufficient_scope", "required": "<scope>" }; a key past its expiry date answers 401 api_key_expired. Only the signed-in owner creates and revokes keys; a key can revoke itself with DELETE /api/v1/keys/current.
The WordPress plugin gets its own key when you connect a site, with send and events:write only: enough for publish notifications, WooCommerce events, test sends and user ID verification, but it can't download your subscribers.
Identity verification
Stops someone pretending to be one of your users to get their notifications.
The browser SDK tells relaybell who a visitor is with externalId (your own user id, often an email). That call is public, so without a check anyone could send your customer's externalId from their own browser and start receiving that customer's notifications β cart reminders, order updates and anything else you send to that id. With identity verification, your server signs each externalId with a secret key that only you and relaybell know, and relaybell only accepts an externalId whose signature is correct.
Turn it on: in the dashboard, Settings β Developer β User ID verification shows your secret key and a Require verified User IDs switch. Start sending userHash first, then switch it on. The WordPress plugin (1.2.4 or newer) does all of this automatically, and the Shopify code on the dashboard's Install page includes the signature.
The signature (userHash)
userHash = lowercase hex HMAC-SHA256, where the key is your secret key β the 64-character hex string, used as plain text (don't hex-decode it) β and the message is the externalId exactly as you send it (numbers as their text form, e.g. 42 β "42"). relaybell compares it without regard to upper/lower case. Make it on your server β never put the secret key in code that runs in the browser.
// Node.js
const crypto = require("crypto");
const userHash = crypto.createHmac("sha256", IDENTITY_SECRET).update(String(externalId)).digest("hex");
// or, with the Node SDK: const { userHash } = require("relaybell"); userHash(IDENTITY_SECRET, externalId)
<?php // PHP
$userHash = hash_hmac('sha256', (string) $externalId, $identitySecret);
// or, with the PHP SDK: Relaybell\Client::userHash($identitySecret, (string) $externalId)
# Python
import hmac, hashlib
user_hash = hmac.new(IDENTITY_SECRET.encode(), str(external_id).encode(), hashlib.sha256).hexdigest()
# or, with the Python SDK (1.1.0 or newer): from relaybell import user_hash; user_hash(IDENTITY_SECRET, external_id)
On Shopify, Liquid runs on Shopify's servers, so the key can go in your theme without reaching the page:
{{ customer.email | hmac_sha256: 'YOUR_SECRET_KEY' }}
Pass it to the browser SDK
RelayBell.init({
projectId: "YOUR_PROJECT_ID",
apiBase: "https://relaybell.com",
externalId: "jane@example.com",
userHash: "<signature made on your server>"
});
// or later, e.g. once the visitor signs in:
RelayBell.identify("jane@example.com", "<signature made on your server>");
The SDK sends userHash wherever it sends externalId. If relaybell answers that the id wasn't verified, the SDK logs one console warning: relaybell: the User ID was not verified β check userHash.
What relaybell does with it
| Request | Result |
|---|---|
Valid userHash | The externalId is used. Response identity: "verified". |
Missing or wrong userHash while verification is required, or a wrong userHash at any time | The externalId is ignored for that request: the browser still subscribes (201/200 as usual) without that id β it isn't linked to the subscriber who has that externalId, no stored user id is set or changed, and that subscriber's earlier opt-out isn't cleared (a browser relaybell already knows stays with its own subscriber). Response identity: "unverified". |
No userHash while verification is not required (the default) | The externalId is accepted as before. Response identity: "unchecked". |
This applies to the public routes the browser SDK uses: POST /api/v1/projects/:id/subscriptions (body fields externalId, userHash; the identity field is left out when no externalId was sent) and POST /api/v1/projects/:id/events (an event's user id is stored only when its userHash is valid, or when no hash is sent and verification isn't required). Calls made with your API key β such as POST /api/v1/events from your server β are already trusted and don't need userHash.
Managing the secret key
| Route | Description |
|---|---|
GET /api/v1/identity | Returns { "secret": "β¦", "required": false }. Creates the secret key on the first call. API key or dashboard session. |
PUT /api/v1/identity | Body { "required": true } turns the requirement on (and creates the secret key if there isn't one yet); false turns it off. Returns { "required": true }. Anything other than true/false answers 400 invalid_required. API key or dashboard session. |
POST /api/v1/identity/rotate | Makes a new secret key and returns { "secret": "β¦" }. The old key stops working at once, so update your site straight away. Dashboard session only β an API key gets 403 session_required, so a leaked API key can't swap your secret. |
curl https://relaybell.com/api/v1/identity -H "Authorization: Bearer rb_live_xxxxx"
curl -X PUT https://relaybell.com/api/v1/identity \
-H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
-d '{ "required": true }'
/config, webhooks or any other response β only in /api/v1/identity. The identityVerification value in GET /api/v1/settings is read-only: PUT /api/v1/settings ignores it, so use PUT /api/v1/identity.Sending notifications
POST /api/v1/notifications β accepts the send and answers 202; delivery happens in the background. With no targeting fields it goes to every subscriber of the website the API key belongs to. Until the account's email address is verified it answers 403 email_not_verified (test sends to your own test devices still work).
curl -X POST https://relaybell.com/api/v1/notifications \
-H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
-d '{
"title": "Flash sale π₯",
"body": "30% off ends tonight β {name|there}!",
"url": "https://shop.example/sale"
}'
Response (202):
{ "ok": true, "notificationId": "5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42", "status": "queued" }
status is "queued" when sending starts now, or "scheduled" when it waits β for a future sendAt, or because your quiet hours are on (the send is held until they end). Keep notificationId: it is how the send appears in /stats and in webhooks, and how you cancel it.
| Field | Description |
|---|---|
title * | Notification title. Required β without it the answer is 400 title_required. Supports {merge} tags such as {name|there} (value, or the text after | when it's empty). |
body | Message text. Supports {merge} tags. |
url | Page opened when the notification is clicked. Left out, your Default link (Settings β Sending) is used. Your UTM tags, if set, are added. |
icon | Small image (URL) shown next to the text. Left out, your Default notification logo (Settings β Notifications) is used. |
image | Large picture (URL). Shown mainly by Chrome and Edge on Windows and Android; Safari, Firefox and Macs leave it out. |
badge | Small one-colour icon (URL of a PNG with a transparent background, about 96Γ96 px) shown in the Android status bar. |
requireInteraction | true keeps the notification on screen until the person clicks or closes it, instead of it fading after a few seconds. Chrome and Edge on computers honour it; Safari and Firefox ignore it. |
tag | A label of your choice. A new notification with the same tag replaces one that is still showing instead of stacking beside it β useful for updates like βYour order is out for deliveryβ. |
actions | Up to 2 buttons: [{action,title,url}]. Each needs a title; action defaults to btn1/btn2; url is kept only when it is an http(s) link. |
externalId | Send to one of your own users: the user id you gave the browser SDK (externalId in init(), or identify()). It reaches every subscribed browser linked to that id. The id must match exactly (case matters). See Send to one user. |
externalIds | The same for a list: ["user_1", "user_2"]. Used instead of externalId when both are sent. If every id is empty, the answer is 400 external_ids_empty β it never falls back to sending to everyone. |
transactional | true for a message a person is waiting for (order update, sign-in alert, a reply): it goes out straight away even during your quiet hours, skips the daily frequency cap, and is never listed in the public on-site inbox (showInbox()). Use it for messages to particular people, not for marketing. |
segmentId / filters | Target a saved segment or inline filters. A segment that isn't in this project answers 400 segment_not_found. Only one way of choosing people is used per send: segmentId first, then filters, then externalIds/externalId β so don't combine them. |
topicId | Only subscribers of a topic β its id or its slug (e.g. "tech-news", any case). Combines with the fields above. An unknown topic answers 400 unknown_topic. |
excludeSegmentId / excludeFilters | Leave a segment out of this send. |
sendAt | ISO 8601 time to schedule the send (status "scheduled"). A time in the past, or one that can't be read, sends now. |
perTimezone + localTime | Deliver at a local time (e.g. "09:00") in each subscriber's time zone. sendAt is ignored. |
smartTime | Deliver at each subscriber's most-active hour. sendAt is ignored. |
ttl | How long (seconds) the push service keeps trying to reach a device that is offline. Default 24 hours, maximum 28 days. |
translations | { "es": {title,body}, ... } β picked by each subscriber's browser language. |
ab | A/B test: { "b": { title, body, url, image } }. |
abTest | Auto-winner: { autoWinner:true, testPercent:20, afterHours:4 } β tests, then sends the winner to the rest. |
Send to one user
First link the browser to your user: pass your own user id as externalId in RelayBell.init(), or call RelayBell.identify(externalId) once the visitor signs in (with userHash if you use identity verification). Then send with that id from your server. If no subscribed browser has the id, the send still answers 202 and simply reaches nobody.
curl -X POST https://relaybell.com/api/v1/notifications \
-H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
-d '{
"externalId": "user_123",
"transactional": true,
"title": "Your order has shipped",
"body": "Order #1042 is on its way.",
"url": "https://shop.example/orders/1042"
}'
// Node.js (npm install relaybell)
const { RelayBell } = require("relaybell");
const rb = new RelayBell({ apiKey: process.env.RELAYBELL_API_KEY });
const { notificationId } = await rb.send({
externalId: "user_123",
transactional: true,
title: "Your order has shipped",
body: "Order #1042 is on its way.",
url: "https://shop.example/orders/1042",
});
# Python (pip install relaybell)
import os
from relaybell import RelayBell
rb = RelayBell(os.environ["RELAYBELL_API_KEY"])
res = rb.send(
externalId="user_123",
transactional=True,
title="Your order has shipped",
body="Order #1042 is on its way.",
url="https://shop.example/orders/1042",
)
print(res["notificationId"])
<?php // PHP (composer require relaybell/relaybell-php)
$rb = new Relaybell\Client(getenv('RELAYBELL_API_KEY'));
$res = $rb->send([
'externalId' => 'user_123',
'transactional' => true,
'title' => 'Your order has shipped',
'body' => 'Order #1042 is on its way.',
'url' => 'https://shop.example/orders/1042',
]);
echo $res['notificationId'];
For several users at once, send "externalIds": ["user_1", "user_2"] instead.
Cancel a scheduled send
POST /api/v1/notifications/:id/cancel β stops a send that is still "scheduled" (a future sendAt, or one held for quiet hours). Answers 200 { "ok": true, "canceled": true }. A send that has already started (including a per-time-zone or smart-time send, which starts at once), has finished, was already canceled, doesn't exist or belongs to another website answers 404 { "error": "not_scheduled" }.
curl -X POST https://relaybell.com/api/v1/notifications/5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42/cancel \
-H "Authorization: Bearer rb_live_xxxxx"
Drafts
Save a notification without sending it, change it as often as you like, then send it. A draft is never queued, never counted against the send limits, never listed in the on-site inbox and never reported to webhooks.
| Request | What it does |
|---|---|
POST /api/v1/notifications with "draft": true | Saves the other fields (the same ones as a send) as a draft. The title may be empty and nothing is checked yet. Answers 201 { "ok": true, "notificationId": "β¦", "status": "draft" }. A website can keep up to 500 drafts (409 too_many_drafts), each up to 256 KB (413 draft_too_large). |
PATCH /api/v1/notifications/:id | Changes the fields you send; the others stay as they were. A field sent as null is cleared. Answers 200 { "ok": true, "notificationId", "status": "draft", "sendBody": { β¦ } }, sendBody being the draft's fields now. |
POST /api/v1/notifications/:id/send | Sends the draft with exactly the checks, limits, quiet hours and sendAt scheduling of a normal send, and answers the same way (202, 400, 403, 429). Fields in the body replace the draft's for this send. The notification keeps its id, and its date becomes the moment it was sent. A sendAt that has passed sends now. |
DELETE /api/v1/notifications/:id | Deletes the draft: 200 { "ok": true, "deleted": true }. |
A notification that isn't a draft answers 409 not_a_draft to PATCH, /send and DELETE; an id that isn't one of this website's answers 404 not_found. To read a draft back, use GET /api/v1/notifications/:id: its sendBody holds the saved fields. Drafts are listed with ?status=draft.
curl -X POST https://relaybell.com/api/v1/notifications \
-H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
-d '{ "draft": true, "title": "Autumn sale", "body": "Up to 30% off" }'
curl -X POST https://relaybell.com/api/v1/notifications/5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42/send \
-H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
-d '{ "sendAt": "2026-11-01T09:00:00Z" }'
Events & automation
POST /api/v1/events β send a behavioral event (server-side; the browser SDK's track() does this too).
{ "event": "purchase", "externalId": "user_123", "properties": { "value": 79.99 } }
Automations (/api/v1/automations) fire a push when an event happens, with an optional delay and a cancelOn event. This powers cart abandonment (a purchase cancels the reminder), back-in-stock and price-drop (see Product alerts), and welcome-back flows. A purchase with a value is attributed as a conversion to the last notification delivered to that subscriber.
POST /api/v1/automations
{ "name":"Cart abandon", "event":"cart.updated", "delaySeconds":3600,
"cancelOn":"purchase", "action":{ "title":"You left {product} π", "url":"{url}" } }
Auto-capture behaviour. Rather than call track() by hand everywhere, opt in and relaybell records pageviews, declarative clicks and scroll for you β only for subscribed or identified visitors, so anonymous traffic makes no noise:
RelayBell.init({ projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com", autoCapture: true });
// granular: autoCapture: { pageviews: true, clicks: true, scroll: true }
// Track any click declaratively β data-rb-* become event properties:
<button data-rb-event="add_to_cart" data-rb-sku="TEE-01" data-rb-price="19.99">Add to cart</button>
Mirror events from Google Tag Manager / GA. You can't trigger push directly from Google Analytics or Meta Pixel β those are analytics and ad tools, not real-time per-user event buses, and their terms don't allow using that data to message individuals. Instead, fire the same events you already track into relaybell too, most easily via a GTM Custom HTML tag on your existing dataLayer events, then segment and automate on them here:
<script>/* GTM Custom HTML tag, fired on your event trigger.
{{Event}} is GTM's built-in variable holding the dataLayer event name. */
if (window.RelayBell) RelayBell.track({{Event}}, {});</script>
To send details with the event, replace {} with an object built from your own Data Layer Variables, for example { productId: {{DLV - productId}} }.
Product alerts (back in stock & price drop)
A product alert is about a product, not one person: your server reports that a product is back in stock or cheaper, and relaybell sends it to everyone who showed interest in that product. It takes two parts.
1. Record interest from the browser. Call track() with the product's id while the visitor is subscribed β interest is only stored for a browser with a push subscription, so check isSubscribed() (or subscribe first):
// "Notify me when back in stock" on a sold-out product.
// For a variable product, send the selected VARIATION's id.
notifyButton.addEventListener("click", async () => {
const r = await RelayBell.subscribeNow(); // must run from the click
if (r.subscribed) RelayBell.track("waitlist.join", { productId: "1234" });
});
// Product page view (counts toward price-drop alerts)
RelayBell.isSubscribed().then((yes) => {
if (yes) RelayBell.track("product.viewed", { productId: "1234" });
});
// Added to cart (counts toward price-drop alerts only for a subscribed browser)
RelayBell.track("cart.updated", { productId: "1234", product: "Blue Shoes", price: 49, url: location.href });
2. Report the product event from your server (API key). Send back_in_stock or price_drop to POST /api/v1/events with no externalId (and no subscriberId or endpoint) β an event that names a person is treated as that person's own event and is not broadcast. Only this authenticated route can broadcast; the browser's track() never does.
curl -X POST https://relaybell.com/api/v1/events \
-H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
-d '{ "event": "price_drop",
"properties": { "productId": "1234-42", "parentId": "1234", "product": "Blue Shoes",
"url": "https://shop.example/p/blue-shoes", "oldPrice": 59, "newPrice": 49 } }'
| Property | Description |
|---|---|
productId * | The product (or variation) that changed. Use the same id you send from the browser. Ids are up to 64 letters, digits and _ . : - β anything else is ignored. |
parentId | For a variation, its parent product β interest recorded on either id counts. |
product, url, oldPrice, newPrice | Optional details for your message, used as {product}, {url}, {oldPrice}, {newPrice} (any other property works as a placeholder too, e.g. {percentOff}). |
Nothing is sent unless you have an enabled automation for that event (Automations β Back-in-stock alerts / Price-drop alerts, or POST /api/v1/automations with "event": "back_in_stock" or "price_drop"). Its title, body and url are templates, e.g. "{product|This item} is back!". Who gets it:
back_in_stockβ everyone on the product's waitlist (waitlist.joinforproductIdorparentId). The waitlist is consumed: each person is taken off it as the alert goes out, so they get one alert per join and a later restock doesn't alert them again. It is sent as a transactional message, so the daily frequency cap can't drop an alert someone asked for.price_dropβ anyone on the waitlist, plus subscribers who viewed (product.viewed) or carted (cart.updated) the product in the last 30 days. This list is not consumed. Price drops are deduplicated: at most oneprice_dropalert per product (keyed byparentId, elseproductId) every 10 minutes β repeats inside that window send nothing.
For product alerts, cancelOn and oncePerSubscriber don't apply; delaySeconds and your quiet hours still do. Unsubscribed people are never included.
Drip sequences
/api/v1/sequences β multi-step journeys. Subscribers enter on subscribe or on an event, then flow through steps with per-step delays; cancelOn drops them if they convert.
POST /api/v1/sequences
{ "name":"Welcome series", "trigger":"subscribe", "cancelOn":"purchase",
"steps":[ { "delaySeconds":0, "title":"Welcome π" },
{ "delaySeconds":86400, "title":"Here's how to start" },
{ "delaySeconds":604800, "title":"A little something for you" } ] }
Segments & topics
/api/v1/segmentsβ saved filters over country, language, url, and customtag:KEY(ops: eq, neq, contains, exists).POST /segments/previewreturns a live match count./api/v1/topicsβ preference-center categories. Subscribers opt in viaRelayBell.setTopics()/showPreferences(); target withtopicId.GET /api/v1/subscribers/exportβ CSV of subscribers.POST /api/v1/subscribers/importβ enrich/pre-seed by external ID with tags + topics (push tokens can't be imported across origins).
Analytics & webhooks
Stats
GET /api/v1/stats β totals for the website, plus its 20 most recent sends (newest first):
{
"subscribers": 1280,
"subscriberDevices": { "desktop": 830, "android": 390, "ios": 60 },
"totals": { "sent": 5400, "clicks": 310, "ctr": 5.7, "conversions": 12, "revenue": 940.5 },
"notifications": [
{ "id": "5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42", "title": "Flash sale π₯", "body": "β¦", "url": "β¦",
"status": "sent", "recipients": 1275, "sent": 1262, "failed": 3, "pruned": 10, "received": 1190,
"clicks": 74, "conversions": 4, "revenue": 316,
"devices": { "desktop": 820, "android": 384, "ios": 58 },
"ab": null, "abStats": null, "error": null,
"scheduled_at": null, "created_at": "2026-10-14T09:30:00.000Z" }
],
"promptAb": null,
"emailVerified": true,
"sendingBlocked": false
}
| Field | Meaning |
|---|---|
subscribers | Browsers currently subscribed (one person on two devices counts twice). subscriberDevices splits them by device: desktop, android, ios, mobile, other. |
totals | All sends ever: sent (pushes the browsers' push services accepted), clicks, ctr (clicks Γ· sent, as a percentage with one decimal), conversions and revenue credited to a notification. |
status | queued, scheduled, sending, sent, failed (reason in error) or canceled. |
recipients, sent, failed, pruned | Browsers targeted; pushes accepted by the push service; pushes that failed; expired subscriptions found and removed during this send. |
received | Pushes the browser confirmed it showed. Usually lower than sent (for example a device that stays offline until the push expires). |
devices | sent split by device type. |
ab, abStats | For an A/B send: the ab settings it was sent with, and { a: { delivered, clicks }, b: { delivered, clicks } }. Otherwise null. |
promptAb | Opt-in prompt A/B results, { a: { shown, subscribed }, b: { shown, subscribed } }, or null if you haven't run one. |
emailVerified, sendingBlocked | Whether the account's email is verified, and whether sends are refused until it is (403 email_not_verified). |
Also: GET /api/v1/stats/growth?days=30 β { days, series: [{ date, subscribed, churned }], totals: { subscribed, churned, net } } (1β365 days), and GET /api/v1/stats/health β { active, reachable, unsubscribed, new30, lost30, dormant, churnRate }.
Reports & export
The numbers mean the same thing in every report: sent, pushes the browsers' push services accepted; delivered, pushes a device confirmed it received (what /stats calls received); clicked; ctr, clicked Γ· sent as a percentage with one decimal (0 when nothing was sent); conversions and revenue credited to a notification. from and to are calendar days (YYYY-MM-DD, both included) in tz: an IANA time zone such as Europe/London. Left out, tz is your website's time zone (the one its quiet hours use), or UTC.
Daily numbers
GET /api/v1/stats/timeseries?from=2026-10-01&to=2026-10-14&tz=Asia/Kolkata β one point per day (days without sends are zeros), the totals, and the same totals for the period of the same length just before. Each notification counts on the day it went out: its scheduled time, or the time it was made for a send that started straight away. Without from and to it covers the last 30 days. A range can be at most 366 days (400 invalid_range).
{
"from": "2026-10-01", "to": "2026-10-14", "tz": "Asia/Kolkata", "interval": "day",
"points": [
{ "date": "2026-10-01", "sent": 1262, "delivered": 1190, "clicked": 74, "conversions": 4, "revenue": 316 }
],
"totals": { "sent": 15400, "delivered": 14120, "clicked": 820, "ctr": 5.3, "conversions": 41, "revenue": 3120.5 },
"previous": { "from": "2026-09-17", "to": "2026-09-30", "sent": 13900, "delivered": 12800, "clicked": 690, "ctr": 5, "conversions": 30, "revenue": 2210 },
"currency": "USD"
}
currency is the revenue currency set for the website, or null.
List of notifications
GET /api/v1/notifications?from&to&status&q&limit&cursor β newest first, 25 per page (limit up to 100). status is one or more of draft, scheduled, queued, sending, sent, failed and canceled, separated by commas; without it, everything except drafts is listed. q searches titles and texts, in any case. from and to filter on the day a notification was made. For the next page, send the nextCursor you got as cursor, with the same filters; it is null on the last page. Pages don't shift when new sends arrive.
{
"items": [
{ "id": "5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42", "title": "Flash sale π₯", "body": "β¦", "status": "sent",
"created_at": "2026-10-14T09:30:00.000Z", "scheduled_at": null,
"recipients": 1275, "sent": 1262, "delivered": 1190, "failed": 3, "pruned": 10,
"clicked": 74, "ctr": 5.9, "conversions": 4, "revenue": 316,
"ab": false, "targetSummary": "VIP buyers Β· Topic: Deals", "automation": false, "error": null }
],
"nextCursor": "WyIyMDI2LTEwLTE0VDA5OjMwOjAwLjAwMDAwMFoiLCI1YjBjβ¦"
}
| Field | Meaning |
|---|---|
scheduled_at | When a scheduled send goes (or went) out; null for one that started straight away. |
recipients, failed, pruned | Browsers targeted; pushes that failed; expired subscriptions found and removed during the send. |
targetSummary | Who it went to, in words: All subscribers, a segment's name, Topic: News, Specific users, Custom filter, One subscriber, Back-in-stock alert, β¦ |
ab, automation | Whether it was an A/B test; whether an automation, a journey or a product alert made it. |
error | Why a failed send never went out (email_not_verified, suspended, segment_missing or dispatch_error), otherwise null. |
A bad filter answers 400 with invalid_status, invalid_range, invalid_tz or invalid_cursor.
One notification
GET /api/v1/notifications/:id β everything stored about one notification, and how it did. An id that isn't one of this website's answers 404 not_found.
{
"notification": { "id": "5b0c2f7e-β¦", "title": "Flash sale π₯", "status": "sent", "target": { "segmentId": "β¦" },
"targetSummary": "VIP buyers", "ab": null, "ab_test": null, "translations": null, "actions": null,
"image": null, "url": "https://shop.example/sale", "ttl": null, "scheduled_at": null, "stats": { β¦ },
"sent": 1262, "delivered": 1190, "clicked": 74, "ctr": 5.9, β¦ },
"funnel": { "recipients": 1275, "sent": 1262, "delivered": 1190, "clicked": 74, "converted": 4, "failed": 3, "expired": 10 },
"devices": [ { "device": "desktop", "sent": 820, "clicked": 51 } ],
"variants": [],
"languages": [ { "language": "en", "sent": 900, "clicked": 55 } ],
"errors": [ { "code": "expired", "count": 10 }, { "code": "timeout", "count": 3 } ],
"revenue": 316, "currency": "USD",
"sendBody": { "title": "Flash sale π₯", "url": "https://shop.example/sale", "segmentId": "β¦" }
}
| Field | Meaning |
|---|---|
devices, languages | sent and clicked by device type and by the subscriber's language (two letters, or unknown). They come from the send's event log, so they are empty once your data retention has removed it; the funnel stays. |
variants | For an A/B send, { variant, sent, clicked, ctr } for versions a and b; otherwise []. |
errors | Why pushes didn't arrive: what the push service answered (an HTTP status, timeout, β¦), and expired for subscriptions found dead and removed. |
sendBody | The POST /api/v1/notifications body that makes this send again, or a draft's saved fields. null when the people were picked at the time (automation, journey and product-alert sends). |
Export to CSV
GET /api/v1/notifications/export.csv β the same filters as the list (no cursor), the same rows in the same order, up to 10,000 of them. Columns: id, created_at, scheduled_at, status, title, target, recipients, sent, failed, clicked, ctr, conversions, revenue, where target is the targetSummary. A title that starts with =, +, - or @ gets a leading ', so a spreadsheet doesn't run it as a formula.
curl "https://relaybell.com/api/v1/notifications/export.csv?from=2026-10-01&to=2026-10-14&status=sent" \
-H "Authorization: Bearer rb_live_xxxxx" -o notifications.csv
Webhooks
relaybell can tell your own server when something happens. Add a webhook in Settings β Developer β Webhooks, or with the API:
curl -X POST https://relaybell.com/api/v1/webhooks \
-H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
-d '{ "url": "https://yourapp.example/webhooks/relaybell", "events": ["notification.clicked"] }'
It answers 201 { "webhook": { "id", "url", "events", "secret", "enabled", "created_at" } }. The secret (starts with whsec_) is shown only in this answer β store it. An empty events list means every event. GET /api/v1/webhooks lists your webhooks with each one's last_status and last_fired_at; PUT /api/v1/webhooks/:id changes url, events or enabled; DELETE /api/v1/webhooks/:id removes one.
Each event is a POST with a JSON body and the headers X-Relaybell-Event (the event name) and X-Relaybell-Signature:
{
"event": "notification.clicked",
"data": { "notificationId": "5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42", "variant": null },
"projectId": "YOUR_PROJECT_ID",
"at": "2026-10-14T09:31:12.345Z"
}
| Event | When it's sent, and its data |
|---|---|
subscriber.created | A new browser subscribed.{ subscriberId, endpointId, externalId, device, country } β externalId is null when not known. |
subscriber.unsubscribed | A visitor turned notifications off through your site (RelayBell.unsubscribe()). Subscriptions a browser drops by itself are removed without this event.{ subscriberId, endpoint } |
notification.sent | A send finished going out.{ notificationId, recipients, sent, failed, pruned } |
notification.clicked | Someone clicked a notification.{ notificationId, variant } β variant is "a" or "b" for an A/B send, otherwise null. |
relaybell sends each event once and waits up to 10 seconds for an answer; it doesn't retry. Answer quickly with any 2xx status and do slow work afterwards.
Verify a webhook signature
X-Relaybell-Signature is sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your webhook secret. Check it before trusting the request β against the exact bytes you received, not JSON you parsed and re-encoded β and with a constant-time comparison. A missing or malformed header must simply fail the check:
// Node.js (Express)
const crypto = require("crypto");
function verifyRelaybell(rawBody, signature, secret) {
if (!Buffer.isBuffer(rawBody) || typeof signature !== "string") return false;
const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"));
const given = Buffer.from(signature);
// timingSafeEqual throws if the lengths differ, so compare the lengths first.
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
// express.raw keeps the exact bytes relaybell signed.
app.post("/webhooks/relaybell", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyRelaybell(req.body, req.get("X-Relaybell-Signature"), process.env.RELAYBELL_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(req.body);
res.sendStatus(200);
// β¦then handle event / data
});
# Python (Flask)
import hashlib, hmac, os
from flask import Flask, abort, request
app = Flask(__name__)
def verify_relaybell(raw_body: bytes, signature: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
# compare_digest is constant-time and returns False (no error) when the lengths differ.
return hmac.compare_digest(expected.encode(), (signature or "").encode())
@app.post("/webhooks/relaybell")
def relaybell_webhook():
signature = request.headers.get("X-Relaybell-Signature", "")
if not verify_relaybell(request.get_data(), signature, os.environ["RELAYBELL_WEBHOOK_SECRET"]):
abort(401)
payload = request.get_json() # {"event": ..., "data": {...}, "projectId": ..., "at": ...}
return "", 200
<?php // PHP
$raw = file_get_contents('php://input'); // the exact bytes relaybell signed
$given = $_SERVER['HTTP_X_RELAYBELL_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('RELAYBELL_WEBHOOK_SECRET'));
if (!hash_equals($expected, $given)) { // constant-time; false for any mismatch
http_response_code(401);
exit;
}
$payload = json_decode($raw, true); // ['event' => ..., 'data' => [...]]
http_response_code(200);
More
- RSS β push (
/api/v1/feeds) and recurring campaigns (/api/v1/schedules). - Copy suggestions β
POST /api/v1/ai/suggestwith{ title, body }returns{ ai, suggestions: [{ title, body }, β¦] }: three quick rewordings of your draft, made from simple templates (aiisfalse), for you to edit. 30 requests an hour.
Errors & rate limits
Errors return a JSON body { "error": "code" } (often with a readable message) and a standard HTTP status:
| Status | Meaning and common codes |
|---|---|
400 | Invalid input β e.g. title_required, external_ids_empty, segment_not_found, unknown_topic; for reports invalid_range, invalid_tz, invalid_status, invalid_cursor. |
401 | invalid_api_key (key not recognised or revoked), api_key_expired (past the expiry date set when it was made) or not_authenticated (no key sent). |
403 | insufficient_scope (the key lacks the scope named in required, see scopes), email_not_verified (verify the account's email address to send) or account_suspended. |
404 | Not found β e.g. not_found, or not_scheduled when cancelling. |
409 | not_a_draft (only a draft can be changed, sent or deleted that way) or too_many_drafts. |
413 | draft_too_large: a draft holds up to 256 KB. |
429 | rate_limited (too many requests) or send_limit_reached (the website's hourly send budget is used up) β see the limits below. |
500 | internal_error β something went wrong on our side; try again later. |
Rate limits
| Requests | Limit | Counted per |
|---|---|---|
POST /api/v1/notifications and /notifications/:id/send (every request; saving a draft isn't counted) | 60 a minute and 600 an hour | API key (dashboard: per account) |
| Broadcasts: sends to everyone, a segment, a topic or filters | 30 per clock hour | website |
Sends to specific users (externalId / externalIds), or transactional: true with a segment or filters | 1,000 per clock hour | website |
| Test sends to your own devices (dashboard) | 120 per clock hour | website |
POST /api/v1/events with an API key | 3,000 a minute | API key |
Browser SDK requests (/api/v1/projects/β¦: config, subscribe, track(), inbox, preferences), and /api/v1/events without a key | 120 a minute | visitor IP address |
| Sign-up, sign-in, email verification, password reset | 30 per 15 minutes | IP address |
POST /api/v1/ai/suggest | 30 an hour | IP address |
POST /api/v1/upload | 60 an hour | IP address |
Going over a request limit answers 429 rate_limited; going over one of the three hourly send budgets (per website) answers 429 send_limit_reached. A limited route's responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window resets). Every 429 also carries Retry-After (seconds): wait that long before trying again, rather than retrying in a loop. The hourly send budgets restart at the top of each hour; a send_limit_reached answer says which budget ran out: { "error": "send_limit_reached", "message": "β¦", "limit": 30, "kind": "broadcast", "retryAfter": 1260 } (kind is broadcast, transactional or test). A send refused this way isn't counted.
Sending rules: quiet hours and a per-subscriber daily frequency cap are set per website (Settings β Sending) and apply automatically β no per-request flags needed. Quiet hours hold a send that would go out now until the quiet time ends; the cap skips subscribers who already got that many notifications in the last 24 hours. Sends with transactional: true skip both.
Compliance / GDPR
relaybell records consent (time, page, policy version) on opt-in. For data-subject requests:
GET /api/v1/subscribers/:externalId/dataβ export everything held about a subscriber (right to access).DELETE /api/v1/subscribers/:externalIdβ erase all of it (right to erasure).- Set a retention window (Settings β Privacy) to auto-prune old event logs. See our Privacy Policy and DPA.
Account & support
The dashboard uses these while you're signed in (session cookie); an API key can't reach them. They're listed so you know what exists.
| Endpoint | What it does |
|---|---|
GET / PATCH /api/v1/account | Your profile; PATCH { name } changes your name. |
POST /api/v1/account/email { email, password } | Sends a confirm link to the new address. The email changes only when the link is opened while signed in (POST /api/v1/account/email/confirm { token }). |
POST /api/v1/account/password { currentPassword, newPassword } | Changes the password and signs out every other session. |
GET /api/v1/account/sessions, DELETE /api/v1/account/sessions[/:id] | Lists where you're signed in; signs out one session, or all the others. |
GET /api/v1/account/export | Downloads your account data as JSON (subscriber lists come from Audience β Export). |
DELETE /api/v1/auth/projects/:id { confirm } | Deletes one website and its data; confirm is the website's name. The account's last website can't be deleted (409 last_site). |
DELETE /api/v1/auth/account { confirm, password } | Deletes the account: sending stops at once, signing in within 14 days offers to restore it (POST /api/v1/auth/account/restore), then everything is removed for good. |
POST /api/v1/support { subject, message, category? } | Emails support with your account and website details; replies come to your email. 5 an hour. |
WordPress & WooCommerce
Install the relaybell WordPress plugin and click Connect to relaybell on its settings page β one click links the site (no keys to copy; manual Project ID + API key entry is still under Advanced). It injects the SDK, serves its own service worker, and can auto-send a push when you publish (tick Send on publish on its settings page β it's off by default). With WooCommerce active (plugin 1.2.2 or newer β older versions sent no WooCommerce events because of a loading bug), it also fires purchase, cart.updated (logged-in shoppers), signup, checkout.started, order.refunded, back_in_stock and price_drop events automatically, adds a βNotify me when back in stockβ button to sold-out products (on variable products it signs the shopper up for the size/colour they picked), and records product views for subscribers so price-drop alerts reach the right people.
Identity verification: plugin 1.2.4 or newer fetches your secret key, signs the user id of each logged-in visitor (userHash) and turns on Require verified User IDs for you. It picks up a new key within a day, or as soon as you open its settings page.
Shopify
Two parts: (1) the storefront code from the dashboard's Install page (choose Shopify), added to your theme, collects subscribers; (2) Shopify webhooks pointed at https://relaybell.com/api/v1/integrations/shopify/<PROJECT_ID> send orders and carts to your automations.
sw.js) from your store's own domain, and Shopify doesn't let you upload it. So on a standard Shopify store the subscribe prompt can't appear until we set push up on a custom domain of yours (a CNAME record) β contact us to switch it on. The webhooks in part (2) work straight away.Shopify topics are mapped automatically:
| Shopify topic | relaybell event |
|---|---|
checkouts/create, checkouts/update | cart.updated |
orders/paid | purchase (value = total) |
orders/fulfilled | order.fulfilled |
Required: paste your Shopify signing secret into Settings β Privacy. relaybell only accepts Shopify updates signed with it; without it, they are acknowledged but ignored, so nobody else can send fake orders or carts.
Signed-in shoppers: the storefront code links a signed-in customer by their email (externalId). Sign it too, so nobody can claim a customer's email to get their notifications β the code on the dashboard's Install β Shopify page already has this, with your key filled in. Then turn on Require verified User IDs.
{% if customer and customer.email != blank %}
externalId: {{ customer.email | json }},
userHash: "{{ customer.email | hmac_sha256: 'YOUR_SECRET_KEY' }}",
{% endif %}
Using the snippet.liquid file instead? Make sure it sets config.userHash = "{{ customer.email | hmac_sha256: 'YOUR_SECRET_KEY' }}"; right after its config.externalId line before you turn on Require β otherwise signed-in shoppers are no longer linked to their orders and carts.
Google Tag Manager
Add a Custom HTML tag in GTM, set to fire on All Pages:
<script src="https://relaybell.com/relaybell-sdk.js"></script>
<script>
window.addEventListener('load', function () {
RelayBell.init({ projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com" });
});
</script>
Server SDKs
Thin wrappers over the REST API for your server β install from your package manager. They use your secret API key, so never put them (or the key) in code that runs in the browser; on web pages, use the relaybell-sdk.js script tag from the Quickstart.
| Language | Install |
|---|---|
| Node.js | npm install relaybell |
| Python | pip install relaybell |
| PHP | composer require relaybell/relaybell-php |
Prefer no package manager? Zipped builds are also available: Node Β· PHP Β· Python.
// Node
const { RelayBell } = require("relaybell");
const rb = new RelayBell({ apiKey: "rb_live_xxxxx" });
await rb.send({ title: "Hello", body: "World", url: "https://example.com" });
await rb.track("purchase", { externalId: "user_123", properties: { value: 42 } });
# PHP
$rb = new Relaybell\Client("rb_live_xxxxx");
$rb->send(["title" => "Hello", "body" => "World", "url" => "https://example.com"]);
# Python
from relaybell import RelayBell
rb = RelayBell("rb_live_xxxxx")
rb.send(title="Hello", body="World", url="https://example.com")
Each SDK can also make the userHash for identity verification, with no network call: Node userHash(secret, externalId), PHP Relaybell\Client::userHash($secret, $externalId), Python user_hash(secret, external_id) (Python SDK 1.1.0 or newer).
Zapier
Connect relaybell to thousands of apps with no code. Actions: Send Notification and Track Event; trigger: New Subscriber. You connect it with an API key from Settings β Developer β API keys. note relaybell isn't in Zapier's public app directory yet (Zapier is still reviewing it), so searching for it in Zapier won't find it β contact us for an invite link.
Browser support
Standards-based Web Push (VAPID) works on Chrome, Firefox, Edge, Opera, Samsung Internet, and Brave (desktop & Android), and Safari 16.1+ on macOS 13 (Ventura) and later, plus Safari on iOS/iPadOS 16.4+ (iPhone/iPad requires adding the site to the Home Screen, which the SDK guides). Dead subscriptions are pruned automatically.