# relaybell developer docs

> relaybell developer documentation — install the web push SDK, send notifications via the REST API, track events, build automations and drip sequences, and integrate with WordPress, Shopify and Zapier.

Web pages: https://relaybell.com/docs · One file per page: `https://relaybell.com/docs/<page>.md` · API reference: https://relaybell.com/docs/api (OpenAPI: https://relaybell.com/openapi.json) · Index for AI assistants: https://relaybell.com/llms.txt · Everything in one file: https://relaybell.com/llms-full.txt

## Contents

### Get started

- [Quickstart](https://relaybell.com/docs/quickstart.md): Add the snippet, host sw.js on your domain and send your first notification.

### Install

- [Installing the SDK](https://relaybell.com/docs/install.md): Hosting the service worker (sw.js, swPath, swScope), apiBase, and the iPhone/iPad Home Screen rule.
- [Next.js](https://relaybell.com/docs/install-nextjs.md): Add relaybell to a Next.js App Router site: load the SDK with next/script, call init() once, serve sw.js from public/ and link signed-in users.
- [React (Vite or Create React App)](https://relaybell.com/docs/install-react.md): Add relaybell to a single-page React app built with Vite or Create React App: the snippet in index.html, sw.js in public/, a subscribe button and signed-in users.
- [Vue and Nuxt](https://relaybell.com/docs/install-vue.md): Add relaybell to a Vue 3 app (Vite) or a Nuxt 3 site: load the SDK once, serve sw.js from public/, add a subscribe button and link signed-in users.
- [WordPress & WooCommerce](https://relaybell.com/docs/install-wordpress.md): The WordPress & WooCommerce plugin: one-click connect, WooCommerce events, back-in-stock button, identity verification.
- [Shopify](https://relaybell.com/docs/install-shopify.md): Shopify storefront code, webhook topic mapping, signing secret and signed-in shoppers.
- [Google Tag Manager](https://relaybell.com/docs/install-gtm.md): Installing relaybell with a Google Tag Manager Custom HTML tag.

### Browser SDK

- [Browser SDK reference](https://relaybell.com/docs/browser-sdk.md): RelayBell.init() options (projectId, apiBase, swPath, swScope, externalId/userHash, tags, consent, autoCapture), window.relaybellInit and every SDK method.
- [Opt-in prompts](https://relaybell.com/docs/prompts.md): Opt-in prompt styles, triggers, copy and A/B test (PUT /api/v1/prompt), and the preview link.

### Sending

- [Sending notifications](https://relaybell.com/docs/sending.md): POST /api/v1/notifications: fields, targeting, scheduling, A/B tests, merge tags and cancelling a scheduled send.
- [Send to one user](https://relaybell.com/docs/send-to-one-user.md): Transactional sends to one of your own users by externalId, with examples in cURL, Node.js, Python and PHP.
- [Drafts](https://relaybell.com/docs/drafts.md): Save a notification as a draft with the API, change it as often as you like, then send it.

### Audience

- [Segments & topics](https://relaybell.com/docs/segments-topics.md): The segment filter format and segment routes, topics, subscriber import/export and bulk labels.
- [Compliance / GDPR](https://relaybell.com/docs/compliance.md): GDPR/CCPA: export or erase one subscriber, consent records, data retention.

### Automation

- [Events & automation](https://relaybell.com/docs/events-automation.md): POST /api/v1/events, conversions and attribution, automations (conditions, delays, cancelOn) and auto-capture.
- [Product alerts](https://relaybell.com/docs/product-alerts.md): Back-in-stock and price-drop alerts: the "Notify me" button, interest events and the broadcast to everyone interested.
- [Drip sequences](https://relaybell.com/docs/sequences.md): Drip sequences (journeys): subscribe and event triggers, message and branch steps, editing rules.

### Developers

- [REST API authentication](https://relaybell.com/docs/authentication.md): Bearer API keys, scopes and which routes need them, creating and revoking keys, expiry.
- [Identity verification](https://relaybell.com/docs/identity.md): Identity verification: userHash = HMAC-SHA256 of the externalId with the website's secret key; /api/v1/identity.
- [Analytics & reports](https://relaybell.com/docs/analytics-reports.md): Stats, daily numbers, the notification list and detail, and CSV export.
- [Webhooks](https://relaybell.com/docs/webhooks.md): Outbound webhooks: events, signatures and how to verify them, retries, the delivery log, resend, test events and auto-disable.
- [Website settings & more routes](https://relaybell.com/docs/website-settings.md): GET and PUT /api/v1/settings for a website, plus the other API routes.
- [Server SDKs](https://relaybell.com/docs/server-sdks.md): The Node.js, Python and PHP server SDKs, including listing notifications and verifying webhooks.
- [Zapier](https://relaybell.com/docs/zapier.md): Zapier actions and trigger (by invite).
- [AI assistants (MCP)](https://relaybell.com/docs/ai-assistants.md): Connect Claude, Cursor, VS Code or Windsurf to relaybell with the relaybell-mcp server, so your assistant can read your results and send notifications after you confirm.

### Reference

- [API reference](https://relaybell.com/docs/api): Every REST endpoint with its scope, parameters, request body, responses, error codes and code samples, generated from the OpenAPI document.
- [Errors & rate limits](https://relaybell.com/docs/errors-limits.md): Every error code with its HTTP status, plus the request and send rate limits.
- [Account & support](https://relaybell.com/docs/account.md): Dashboard-only account and support endpoints.
- [Browser support](https://relaybell.com/docs/browser-support.md): Supported browsers, and what iPhone/iPad need.
- [Changelog](https://relaybell.com/docs/changelog.md): What changed for developers: the docs in the new site and relaybell for AI assistants; webhooks v2, server SDKs 1.2.0 and the OpenAPI reference; WordPress plugin 1.2.5.

## Quickstart

Section: Get started · Web page: https://relaybell.com/docs/quickstart · Markdown: https://relaybell.com/docs/quickstart.md

> Add the snippet, host sw.js on your domain and send your first notification.

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](https://relaybell.com/docs/install-wordpress) instead — it does steps 1 and 2 for you.

**1. Add the snippet** to every page, just before `</body>`:

```html
<script src="https://relaybell.com/relaybell-sdk.js"></script>
<script>
  RelayBell.init({ projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com" });
</script>
```

Building with a JavaScript framework? See [Next.js](https://relaybell.com/docs/install-nextjs), [React](https://relaybell.com/docs/install-react) or [Vue and Nuxt](https://relaybell.com/docs/install-vue).

**2. Host the service worker.** Download [sw.js](https://relaybell.com/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](https://relaybell.com/docs/install).)

> **Warning:** Your site must be served over **HTTPS**: browsers allow service workers and push only on secure pages (`http://localhost` works for testing).

**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 → Developers → API keys and POST to the REST API (see [Sending](https://relaybell.com/docs/sending)) — or send from the dashboard.

## Installing the SDK

Section: Install · Web page: https://relaybell.com/docs/install · Markdown: https://relaybell.com/docs/install.md

> Hosting the service worker (sw.js, swPath, swScope), apiBase, and the iPhone/iPad Home Screen rule.

The SDK needs a service worker on your own domain. The default `swPath` is `/sw.js` — host our [sw.js](https://relaybell.com/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](https://relaybell.com/docs/install-wordpress) serves its own service worker, so there's no file to upload. Shopify doesn't let you upload this file — see [Shopify](https://relaybell.com/docs/install-shopify).

**Already have a service worker at `/sw.js`** (a PWA, for example)? Keep it, and host relaybell's `sw.js` in its own folder with `swPath` and `swScope` (for example `"/relaybell/sw.js"` and `"/relaybell/"`). A worker with a narrower scope still receives push, so the two don't clash.

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

> **Note:** On **iOS/iPadOS**, web push only works once a visitor adds your site to their Home Screen (Apple's rule). The SDK detects iOS and shows an "Add to Home Screen" hint automatically.

## Next.js

Section: Install · Web page: https://relaybell.com/docs/install-nextjs · Markdown: https://relaybell.com/docs/install-nextjs.md

> Add relaybell to a Next.js App Router site: load the SDK with next/script, call init() once, serve sw.js from public/ and link signed-in users.

The browser SDK is a script tag, not an npm package. In the App Router you load it with `next/script` from a small client component in your root layout, start it once in `onLoad`, and serve relaybell's service worker from `public/`. Your **Project ID** is on the dashboard's **Install** page.

### 1. Add the service worker {#service-worker}

Download [sw.js](https://relaybell.com/sw.js) and save it as `public/sw.js`. Next.js serves everything in `public/` from the root of your site, so it opens at `https://yoursite.com/sw.js` — the default `swPath`. Browsers only run a service worker from your own domain, so push can't work without this file.

> **Note:** Already have a service worker at `/sw.js` (from a PWA plugin, for example)? Keep it. Save relaybell's as `public/relaybell/sw.js` instead and pass `swPath: "/relaybell/sw.js"` and `swScope: "/relaybell/"` to `init()` — see [Installing the SDK](https://relaybell.com/docs/install).

### 2. Load the SDK once {#load-the-sdk}

Create a client component that loads the script after the page is interactive and calls `RelayBell.init()` when it has loaded:

```tsx
// app/relaybell.tsx
"use client";

import Script from "next/script";

let started = false; // init() once per page load

export function RelayBell() {
  return (
    <Script
      src="https://relaybell.com/relaybell-sdk.js"
      strategy="afterInteractive"
      onLoad={() => {
        if (started) return;
        started = true;
        window.RelayBell?.init({ projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com" });
        // Your own signal for code that waits for the SDK (see "Link signed-in users").
        window.dispatchEvent(new Event("relaybell:loaded"));
      }}
    />
  );
}
```

Render it once, in the root layout, so it stays loaded while visitors move between pages:

```tsx
// app/layout.tsx
import { RelayBell } from "./relaybell";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <RelayBell />
      </body>
    </html>
  );
}
```

**Call `init()` once per page.** A second call starts again from its own options, so don't put it in a `useEffect` of a page component. `onLoad` runs once, when the script has loaded; client-side navigation doesn't load it again.

The Project ID isn't a secret (it's in your page's HTML), so `process.env.NEXT_PUBLIC_RELAYBELL_PROJECT_ID` is fine for it. Your **API key** (`rb_live_…`) is: never put it in a `NEXT_PUBLIC_` variable or a client component — use it only in server code, with the [server SDKs](https://relaybell.com/docs/server-sdks).

> **Note:** If your site sends a Content-Security-Policy header, it must at least allow the script and the API calls: `https://relaybell.com` in `script-src` and `connect-src`.

### 3. TypeScript types {#types}

The SDK puts `RelayBell` on `window`. Declare the parts you use, for example in `types/relaybell.d.ts`:

```ts
export {};

declare global {
  interface Window {
    RelayBell?: {
      init(options: {
        projectId: string;
        apiBase?: string;
        swPath?: string;
        swScope?: string;
        externalId?: string;
        userHash?: string;
        tags?: Record<string, string | number | boolean>;
        requireConsent?: boolean;
        policyVersion?: string;
        autoCapture?: boolean | { pageviews?: boolean; clicks?: boolean; scroll?: boolean };
      }): Promise<unknown>;
      subscribeNow(): Promise<{ supported: boolean; subscribed: boolean; permission?: NotificationPermission; reason?: string }>;
      isSubscribed(): Promise<boolean>;
      identify(externalId: string | null, userHash?: string): Promise<void>;
      unsubscribe(): Promise<{ unsubscribed: boolean }>;
      track(event: string, properties?: Record<string, unknown>): Promise<unknown>;
    };
  }
}
```

Every option and method is described in the [Browser SDK](https://relaybell.com/docs/browser-sdk) reference.

### 4. Link signed-in users {#identify}

To send to one of your users by your own user id ([Send to one user](https://relaybell.com/docs/send-to-one-user)), tell the SDK who is signed in. Make the signature (`userHash`) on the server — it's the HMAC-SHA256 of the id, keyed with your website's secret key from **Settings → Developers → User ID verification** ([Identity verification](https://relaybell.com/docs/identity)):

```ts
// lib/relaybell-hash.ts: server code only (the secret key must never reach the browser)
import { createHmac } from "node:crypto";

export function relaybellUserHash(userId: string) {
  return createHmac("sha256", process.env.RELAYBELL_IDENTITY_SECRET!).update(String(userId)).digest("hex");
}
```

A small client component hands the id and its signature to the SDK, waiting for the script if it hasn't loaded yet (on the first page it loads after the page becomes interactive):

```tsx
// app/relaybell-identify.tsx
"use client";

import { useEffect } from "react";

export function RelayBellIdentify({ userId, userHash }: { userId: string; userHash: string }) {
  useEffect(() => {
    const run = () => window.RelayBell?.identify(userId, userHash);
    if (window.RelayBell) run();
    else window.addEventListener("relaybell:loaded", run, { once: true });
    return () => window.removeEventListener("relaybell:loaded", run);
  }, [userId, userHash]);
  return null;
}
```

Render it from a Server Component that knows who is signed in, for example the layout of your signed-in area:

```tsx
// app/(account)/layout.tsx
import { RelayBellIdentify } from "../relaybell-identify";
import { relaybellUserHash } from "@/lib/relaybell-hash";
import { getCurrentUser } from "@/lib/auth"; // your own sign-in code

export default async function AccountLayout({ children }: { children: React.ReactNode }) {
  const user = await getCurrentUser();
  return (
    <>
      {user ? <RelayBellIdentify userId={String(user.id)} userHash={relaybellUserHash(String(user.id))} /> : null}
      {children}
    </>
  );
}
```

Only ever sign the id of the user who is signed in on that request: an endpoint that signs any id it is given would let anyone claim any user. `identify()` can run before `init()` has finished, and calling it again with the same values does nothing.

On a shared browser, call `window.RelayBell?.unsubscribe()` when someone signs out of a site without identity verification — see `identify()` under [Methods](https://relaybell.com/docs/browser-sdk#sdk-methods).

### 5. A subscribe button (optional) {#subscribe-button}

relaybell shows your opt-in prompt by itself (set it up under [Opt-in prompts](https://relaybell.com/docs/prompts)). For your own button, call `subscribeNow()` from the click:

```tsx
"use client";

import { useEffect, useState } from "react";

export function NotifyButton() {
  const [on, setOn] = useState(false);
  useEffect(() => {
    const check = () => window.RelayBell?.isSubscribed().then(setOn);
    if (window.RelayBell) check();
    else window.addEventListener("relaybell:loaded", check, { once: true });
    return () => window.removeEventListener("relaybell:loaded", check);
  }, []);
  if (on) return <p>Notifications are on.</p>;
  return (
    <button
      onClick={async () => {
        const result = await window.RelayBell?.subscribeNow();
        if (result?.subscribed) setOn(true);
      }}
    >
      Turn on notifications
    </button>
  );
}
```

### Check it works {#check}

Open your site over HTTPS (or `http://localhost`), accept the prompt, then send a test from the dashboard. Things to look for:

- `https://yoursite.com/sw.js` opens and shows relaybell's service worker.
- The browser console has no `relaybell:` warnings.
- With `autoCapture: true`, route changes in your app are recorded as `page_view` events too: the SDK notices client-side navigation by itself.

## React (Vite or Create React App)

Section: Install · Web page: https://relaybell.com/docs/install-react · Markdown: https://relaybell.com/docs/install-react.md

> Add relaybell to a single-page React app built with Vite or Create React App: the snippet in index.html, sw.js in public/, a subscribe button and signed-in users.

A single-page React app loads one HTML page, so the plain [Quickstart](https://relaybell.com/docs/quickstart) snippet is all you need: it goes in `index.html`, and your components talk to `window.RelayBell`. Your **Project ID** is on the dashboard's **Install** page. (Using Next.js? See [Next.js](https://relaybell.com/docs/install-nextjs).)

### 1. Add the snippet to index.html {#snippet}

Paste it just before `</body>` — in Vite that's `index.html` at the project root, in Create React App `public/index.html`:

```html
<script src="https://relaybell.com/relaybell-sdk.js"></script>
<script>
  RelayBell.init({ projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com" });
</script>
```

The page loads it once, and it stays loaded while your router changes pages, so `init()` runs once per page load — as it should (a second call starts again from its own options). Don't call `init()` from a component.

Both tools load your app's own code after these classic scripts have run, so `window.RelayBell` is there by the time your components mount.

### 2. Add the service worker {#service-worker}

Download [sw.js](https://relaybell.com/sw.js) and save it as `public/sw.js`. Vite and Create React App both serve `public/` from the root of your site, so it opens at `https://yoursite.com/sw.js` — the default `swPath`.

> **Note:** If your app already registers its own service worker at `/sw.js` (a PWA plugin, for example), keep it: save relaybell's as `public/relaybell/sw.js` and add `swPath: "/relaybell/sw.js", swScope: "/relaybell/"` to `init()`. See [Installing the SDK](https://relaybell.com/docs/install).

### 3. TypeScript types {#types}

If your app uses TypeScript, declare what you use from `window.RelayBell`, for example in `src/relaybell.d.ts`:

```ts
export {};

declare global {
  interface Window {
    RelayBell?: {
      subscribeNow(): Promise<{ supported: boolean; subscribed: boolean; permission?: NotificationPermission; reason?: string }>;
      isSubscribed(): Promise<boolean>;
      identify(externalId: string | null, userHash?: string): Promise<void>;
      unsubscribe(): Promise<{ unsubscribed: boolean }>;
      setTags(tags: Record<string, string | number | boolean>): Promise<Record<string, unknown>>;
      track(event: string, properties?: Record<string, unknown>): Promise<unknown>;
    };
  }
}
```

### 4. A subscribe button (optional) {#subscribe-button}

relaybell shows your opt-in prompt by itself ([Opt-in prompts](https://relaybell.com/docs/prompts)). For your own button, call `subscribeNow()` from the click — browsers only show the permission prompt in answer to one:

```tsx
import { useEffect, useState } from "react";

export function NotifyButton() {
  const [on, setOn] = useState(false);
  useEffect(() => {
    window.RelayBell?.isSubscribed().then(setOn);
  }, []);
  if (on) return <p>Notifications are on.</p>;
  return (
    <button
      onClick={async () => {
        const result = await window.RelayBell?.subscribeNow();
        if (result?.subscribed) setOn(true);
        else if (result && !result.supported) alert("This browser can't show notifications from this site.");
      }}
    >
      Turn on notifications
    </button>
  );
}
```

On an iPhone or iPad, `supported` is `false` until the visitor adds your site to their Home Screen — see [Browser support](https://relaybell.com/docs/browser-support).

### 5. Link signed-in users {#identify}

To [send to one of your users](https://relaybell.com/docs/send-to-one-user) by your own user id, call `identify()` once you know who is signed in. With [identity verification](https://relaybell.com/docs/identity), pass the `userHash` your server made for that user (the HMAC-SHA256 of the id with your website's secret key) — make it on the server and send it to the app with the signed-in user's details; never put the secret key in the app.

```tsx
import { useEffect } from "react";

// user = the signed-in user from your own auth code, with userHash added by your server
export function useRelayBellUser(user: { id: string; relaybellHash: string } | null) {
  const id = user?.id;
  const hash = user?.relaybellHash;
  useEffect(() => {
    if (id) window.RelayBell?.identify(id, hash);
  }, [id, hash]);
}
```

Calling `identify()` again with the same values does nothing, so it's fine in an effect. On a shared browser, call `window.RelayBell?.unsubscribe()` when someone signs out of a site without identity verification — see `identify()` under [Methods](https://relaybell.com/docs/browser-sdk#sdk-methods).

### 6. Track events {#events}

`track()` feeds your [automations](https://relaybell.com/docs/events-automation) (abandoned cart, product alerts, journeys) and conversion reports:

```tsx
window.RelayBell?.track("cart.updated", { productId: "1234", product: "Blue Shoes", price: 49, url: location.href });
window.RelayBell?.track("purchase", { value: 79.99, orderId: "A123" });
```

Or add `autoCapture: true` to `init()`: it records page views — including your router's page changes — and clicks on elements with `data-rb-event`. See [Auto-capture](https://relaybell.com/docs/events-automation#auto-capture).

## Vue and Nuxt

Section: Install · Web page: https://relaybell.com/docs/install-vue · Markdown: https://relaybell.com/docs/install-vue.md

> Add relaybell to a Vue 3 app (Vite) or a Nuxt 3 site: load the SDK once, serve sw.js from public/, add a subscribe button and link signed-in users.

The browser SDK is a script tag that puts `RelayBell` on `window`. Load it once per page, serve relaybell's `sw.js` from your own domain, and call the SDK from your components when you need it. Your **Project ID** is on the dashboard's **Install** page.

### Vue 3 with Vite {#vue}

Paste the [Quickstart](https://relaybell.com/docs/quickstart) snippet into `index.html` (at the project root), just before `</body>`:

```html
<script src="https://relaybell.com/relaybell-sdk.js"></script>
<script>
  RelayBell.init({ projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com" });
</script>
```

The page loads it once and Vue Router changes pages without reloading it, so `init()` runs once per page load, as it should (a second call starts again from its own options). Your app's code runs after this script, so `window.RelayBell` is there when your components mount.

Then download [sw.js](https://relaybell.com/sw.js) and save it as `public/sw.js`: Vite serves `public/` from the root of your site, so it opens at `https://yoursite.com/sw.js`, the default `swPath`.

### Nuxt 3 {#nuxt}

Nuxt renders on the server, so load the SDK through the page head and let it start itself. Set the options in `window.relaybellInit` before the script: the SDK starts with them once it loads (on the page's `load` event, or straight away if that has passed).

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      script: [
        { innerHTML: 'window.relaybellInit = { projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com" };' },
        { src: "https://relaybell.com/relaybell-sdk.js", defer: true },
      ],
    },
  },
});
```

Save [sw.js](https://relaybell.com/sw.js) as `public/sw.js` (Nuxt 3 serves `public/` from the root). Call the SDK only in code that runs in the browser: `onMounted()`, event handlers, or a plugin named `*.client.ts`.

> **Note:** Already have a service worker at `/sw.js` (a PWA module, for example)? Keep it: save relaybell's as `public/relaybell/sw.js` and add `swPath: "/relaybell/sw.js", swScope: "/relaybell/"` to the options. See [Installing the SDK](https://relaybell.com/docs/install).

### A subscribe button (optional) {#subscribe-button}

relaybell shows your opt-in prompt by itself ([Opt-in prompts](https://relaybell.com/docs/prompts)). For your own button, call `subscribeNow()` from the click — browsers only show the permission prompt in answer to one:

```vue
<script setup>
import { onMounted, ref } from "vue";

const on = ref(false);

onMounted(async () => {
  on.value = (await window.RelayBell?.isSubscribed()) === true;
});

async function subscribe() {
  const result = await window.RelayBell?.subscribeNow();
  if (result?.subscribed) on.value = true;
}
</script>

<template>
  <p v-if="on">Notifications are on.</p>
  <button v-else type="button" @click="subscribe">Turn on notifications</button>
</template>
```

In Nuxt the SDK loads with `defer` and starts on the page's `load` event, so on the very first page it may not be there yet when `onMounted()` runs: the button then shows even for a subscribed browser. A click still works, since `subscribeNow()` waits for the SDK to finish starting.

### Link signed-in users {#identify}

To [send to one of your users](https://relaybell.com/docs/send-to-one-user) by your own user id, call `identify()` once you know who is signed in. With [identity verification](https://relaybell.com/docs/identity), pass the `userHash` your server made for that user (the HMAC-SHA256 of the id, keyed with your website's secret key); never put the secret key in the app.

```js
// after sign-in, in browser code; user comes from your own auth code
window.RelayBell?.identify(user.id, user.relaybellHash);
```

Calling it again with the same values does nothing. If you know the user when the page is rendered, you can put `externalId` and `userHash` into the `init()` options (or `window.relaybellInit`) instead. On a shared browser, call `window.RelayBell?.unsubscribe()` when someone signs out of a site without identity verification — see `identify()` under [Methods](https://relaybell.com/docs/browser-sdk#sdk-methods).

### Track events {#events}

`track()` feeds your [automations](https://relaybell.com/docs/events-automation) and conversion reports:

```js
window.RelayBell?.track("cart.updated", { productId: "1234", product: "Blue Shoes", price: 49, url: location.href });
```

Or add `autoCapture: true` to the options: page views (including Vue Router page changes) and clicks on elements with `data-rb-event` are recorded for you. See [Auto-capture](https://relaybell.com/docs/events-automation#auto-capture).

## WordPress & WooCommerce

Section: Install · Web page: https://relaybell.com/docs/install-wordpress · Markdown: https://relaybell.com/docs/install-wordpress.md

> The WordPress & WooCommerce plugin: one-click connect, WooCommerce events, back-in-stock button, identity verification.

Install the [relaybell WordPress plugin](https://relaybell.com/relaybell-wordpress.zip) 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](https://relaybell.com/docs/product-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](https://relaybell.com/docs/identity) for you. It picks up a new key within a day, or as soon as you open its settings page.

## Shopify

Section: Install · Web page: https://relaybell.com/docs/install-shopify · Markdown: https://relaybell.com/docs/install-shopify.md

> Shopify storefront code, webhook topic mapping, signing secret and signed-in shoppers.

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.

> **Warning:** **Push on Shopify needs one extra step from us.** Browsers only accept the service worker (`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](https://relaybell.com/contact) 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`, `carts/update` | `cart.updated` with `{ cartTotal, url, items }` (`url` is the abandoned-checkout link; a cart has none, so it links to your store's cart page: the **Website address** from Settings → General followed by `/cart`, or `https://<your-store>.myshopify.com/cart` when that isn't set) |
| `orders/paid` or `orders/create` | `purchase` with `{ value, currency, orderId, url }` (`value` = order total). Subscribe to only one of the two, or each order counts twice. |
| `orders/fulfilled` | `order.fulfilled` with `{ orderId, url }` |

The person is found by the order's or checkout's customer email (their `externalId`), so these events reach shoppers whose browser the storefront code linked to that email. Other topics are acknowledged and ignored.

**Required:** paste your **Shopify signing secret** into Settings → Privacy. relaybell only accepts Shopify updates signed with it (`X-Shopify-Hmac-Sha256`; a wrong signature answers `401 invalid_hmac`); without the secret, 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](https://relaybell.com/docs/identity).

```liquid
{% 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

Section: Install · Web page: https://relaybell.com/docs/install-gtm · Markdown: https://relaybell.com/docs/install-gtm.md

> Installing relaybell with a Google Tag Manager Custom HTML tag.

Add a **Custom HTML** tag in GTM, set to fire on *All Pages*:

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

To send the events you already track in GTM (your dataLayer events) to relaybell too, see [Auto-capture & Google Tag Manager](https://relaybell.com/docs/events-automation#auto-capture).

## Browser SDK reference

Section: Browser SDK · Web page: https://relaybell.com/docs/browser-sdk · Markdown: https://relaybell.com/docs/browser-sdk.md

> RelayBell.init() options (projectId, apiBase, swPath, swScope, externalId/userHash, tags, consent, autoCapture), window.relaybellInit and every SDK method.

### init() and options {#sdk-init}

```js
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
  autoCapture: false              // true = record pageviews + data-rb-event clicks (see below)
});
```

| Option | Description |
| --- | --- |
| `projectId` \* | Your website's Project ID (dashboard: **Install** page, or Settings → Developers). Without it `init()` throws `relaybell: projectId is required`. |
| `apiBase` | The relaybell address. Left out (or `null`), the origin `relaybell-sdk.js` was loaded from; `""` means the page's own origin. A trailing `/` is removed. |
| `swPath` | Where your copy of `sw.js` is, on your own domain. Default `"/sw.js"`. A full URL on another host (e.g. rewritten by a CDN plugin) is cut back to its path on the page's domain, since browsers only accept a same-origin service worker. |
| `swScope` | Optional scope for the service-worker registration, e.g. `"/folder/"` when `sw.js` isn't at the root. It is also how the SDK finds the registration again on later pages, so set it whenever `sw.js` lives in a folder. Left out, the browser's default (the folder `sw.js` is in). |
| `externalId`, `userHash` | Your own user id for this visitor, and your server's signature of it (see [Identity verification](https://relaybell.com/docs/identity)). Sent with the subscription and with every `track()` event. |
| `tags` | Custom attributes, `{ key: value }`; values are stored as text. Used by segments (`tag:KEY`) and [merge tags](https://relaybell.com/docs/sending#merge-tags) (`{KEY}`). Keys starting with `rb_` are reserved and ignored. |
| `requireConsent`, `policyVersion` | With `requireConsent: true` the SDK registers no service worker, shows no opt-in prompt and sends no auto-captured events until you call `grantConsent()` (remembered in the browser's `localStorage`; `revokeConsent()` forgets it and unsubscribes). On-site messages still show. `policyVersion` is stored with the opt-in. |
| `autoCapture` | `true` records `page_view` (including single-page-app route changes) and `[data-rb-event]` clicks; or pass `{ pageviews, clicks, scroll }` to choose (`scroll` sends `scroll_depth` at 25/50/75/100%; it is off unless you turn it on). Only for subscribed or identified visitors, and nothing at all while `requireConsent` is waiting for consent. See [Events](https://relaybell.com/docs/events-automation). |

`init()` returns a promise. Where push can't work it resolves `{ supported: false, needsInstall, reason }` (`needsInstall` is `true` on an iPhone/iPad outside a Home Screen web app); while waiting for consent, `{ supported: true, waitingForConsent: true }`.

**Call `init()` once per page.** A second call starts again from its own options. In React or Next.js, load the script with `next/script` (`strategy="afterInteractive"`) and call `RelayBell.init()` in its `onLoad` inside a client component (or set `window.relaybellInit`, below), and put `sw.js` in Next's `public/` folder so it is served at `/sw.js`. Step by step: [Next.js](https://relaybell.com/docs/install-nextjs), [React](https://relaybell.com/docs/install-react), [Vue and Nuxt](https://relaybell.com/docs/install-vue).

**Script loaded late?** If your own code may run before `relaybell-sdk.js` has loaded (a delayed or consent-gated script), set the options on the page instead — the SDK starts itself with them once it loads (on the page's `load` event, or straight away if that has passed), unless `init()` already ran:

```html
<script>window.relaybellInit = { projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com" };</script>
<script src="https://relaybell.com/relaybell-sdk.js" defer></script>
```

### Methods {#sdk-methods}

| Method | What it does |
| --- | --- |
| `init(opts)` | Registers the SW, shows the opt-in prompt, subscribes (see [options](https://relaybell.com/docs/browser-sdk#sdk-init)). |
| `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(externalId, userHash)` | Links this browser to your own user id after the page has loaded (for example once the visitor signs in): an existing subscription is saved again with the id, and later `track()` events carry it. A browser with no id yet joins that user (a second device joins the same profile). A browser already linked to a different user moves to the new one only with a valid `userHash` (an account switch on a shared browser); without one it stays with the first user, so on a site without [identity verification](https://relaybell.com/docs/identity) call `unsubscribe()` when someone signs out of a shared browser. Calling it again with the same values does nothing; `identify(null)` stops sending an id from this page. `userHash` is optional unless you require [identity verification](https://relaybell.com/docs/identity). Safe to call before `init()`. |
| `unsubscribe()` | Removes this browser's push subscription and tells relaybell, which records the opt-out (webhook `subscriber.unsubscribed`). Resolves `{ unsubscribed: true }`. |
| `isSupported()` | `{ supported, browser, os, needsInstall, reason }` — feature detection, no network call. |
| `setTags({...})` | Merges the tags into the ones given to `init()` and, if this browser is subscribed, saves them (a key is overwritten, never removed). Resolves the merged tags. |
| `setTopics(["news"])` | Saves the topics this subscriber wants, by **slug**. It **replaces** the whole list (`[]` leaves every topic); unknown slugs are skipped. Works only for a browser with a push subscription: resolves `{ ok: true, applied }` (how many topics were saved), or `{ error: "subscriber_not_found" }`. |
| `showPreferences()` | Opens a preference-center modal of your topics, ticked with the subscriber's current choices; saving calls `setTopics()`. |
| `showInbox()` | Opens a modal of your 15 most recent broadcasts (never transactional, automation, journey or one-person sends). |
| `track(event, props)` | Sends a behavioral event (fires automations, journeys and attribution). From the browser, the person is the subscriber who owns **this browser's push subscription**: an event from a browser that isn't subscribed is stored but triggers nothing for anyone. Its `externalId` is kept only when verified, or when verification isn't required and no `userHash` is sent. |
| `trackConversion(value, props)` | Shorthand for `track("purchase", { value, ...props })`. |
| `grantConsent()` / `revokeConsent()` | For cookie-consent (CMP) gating when `requireConsent` is set: `grantConsent()` starts the SDK as `init()` would have; `revokeConsent()` forgets the consent and calls `unsubscribe()`. |

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

Section: Browser SDK · Web page: https://relaybell.com/docs/prompts · Markdown: https://relaybell.com/docs/prompts.md

> Opt-in prompt styles, triggers, copy and A/B test (PUT /api/v1/prompt), and the preview link.

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; `card` is the same box), `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.

```bash
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` | Default `true`. `false` = never ask automatically (visitors can still subscribe via your own button + `subscribeNow()`). |
| `type` | `slidein` · `card` (another name for the same slide-in box) · `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` takes the prompt as `{ "prompt": { … } }` (or the fields at the top level) and answers `{ ok: true, prompt }`. It accepts the fields listed above only — anything else is dropped, and an unknown `type`, `trigger` or `position` falls back to `slidein`, `delay` or the style's default — 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.

## Sending notifications

Section: Sending · Web page: https://relaybell.com/docs/sending · Markdown: https://relaybell.com/docs/sending.md

> POST /api/v1/notifications: fields, targeting, scheduling, A/B tests, merge tags and cancelling a scheduled send.

**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).

```bash
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`):

```json
{ "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). A send to *everyone* from an account less than 7 days old that would reach more than 5,000 browsers answers `"held_for_review"` with a `message`: our team checks a new account's first large sends before they go out (usually within a working day), and you can cancel it meanwhile. A narrower broadcast from such an account can be held the same way just before it goes out. Sends to specific people are never held. Keep `notificationId`: it is how the send appears in [`/stats`](https://relaybell.com/docs/analytics-reports#stats) and in [webhooks](https://relaybell.com/docs/webhooks), and how you cancel it.

| Field | Description |
| --- | --- |
| `title` \* | Notification title. Required — without it the answer is `400 title_required`. Supports [merge tags](https://relaybell.com/docs/sending#merge-tags) such as `{name\|there}` (the subscriber's `name` tag, or the text after `\|` when it's empty). |
| `body` | Message text. Supports [merge tags](https://relaybell.com/docs/sending#merge-tags). |
| `url` | Page opened when the notification is clicked. Left out, your **Default link** (Settings → Sending → Links) is used. Your UTM tags, if set, are added. Supports merge tags. |
| `icon` | Small image (URL) shown next to the text. Left out, your **Default notification logo** (Settings → General) 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](https://relaybell.com/docs/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 in the [segment filter format](https://relaybell.com/docs/segments-topics#segments). A segment that isn't in this project answers `400 segment_not_found`; one deleted before a scheduled send goes out makes that send fail (`error: "segment_missing"`) — it never falls back to everyone. 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 (or the people matching inline filters) out of this send. `excludeSegmentId` wins when both are sent; an unknown one answers `400 segment_not_found`. |
| `sendAt` | ISO 8601 time to schedule the send (status `"scheduled"`). A time in the past, or one that can't be read, sends now. A send with `sendAt` isn't moved by quiet hours. |
| `perTimezone` + `localTime` | Deliver at a local time (`"H:MM"` or `"HH:MM"`, e.g. `"09:00"`) in each subscriber's time zone. `sendAt` is ignored, and without a valid `localTime` it sends normally. |
| `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}, ... }` — keyed by two-letter language, picked by each subscriber's browser language; others get the main text. |
| `ab` | A/B test: `{ "b": { title, body, url, image } }` — each browser gets version A or B (fields left out of B come from A). Ignored unless `b` has at least one field. |
| `abTest` | Auto-winner, with `ab`: `{ autoWinner:true, testPercent:20, afterHours:4 }` — sends both versions to `testPercent`% (1–90, default 20), then after `afterHours` (1–168, default 4) sends the version with the higher click rate to the rest (with no clicks yet, the rest get the normal A/B split). |

### Merge tags (personalization) {#merge-tags}

Write `{key}` or `{key|fallback}` in a send's `title`, `body` or `url`, and each subscriber gets their own value. An empty or unknown value becomes the fallback (the text after `|`), or nothing when there is none. Keys are letters, digits, `_`, `.` and `-`, and are case-sensitive.

| Tag | Value |
| --- | --- |
| `{external_id}` | The user id linked to the subscriber (`externalId`). |
| `{url}` | The last page of your site the subscriber was on when they subscribed or were last updated. |
| `{language}` | The browser's language, e.g. `en-GB`. |
| `{country}` | The subscriber's 2-letter, upper-case country code (ISO 3166-1, e.g. `IN`), worked out from the visitor's IP address when the browser subscribes or is saved again; empty when unknown. It can't be set from the SDK — use a tag for that. |
| `{KEY}` | Any of the subscriber's tags (`tags` in `init()`, `setTags()`, an import or a bulk label), e.g. `{name\|there}` or `{plan}`. A tag with the same name as a built-in one wins. |

Tags are filled in only when the main `title`, `body` or `url` contains one; then the A/B version B and the `translations` are personalized too. In [automation](https://relaybell.com/docs/events-automation#automations), [journey](https://relaybell.com/docs/sequences) and [product alert](https://relaybell.com/docs/product-alerts) messages the same `{key|fallback}` syntax takes its values from the **event's properties** instead (for example `{product}`, `{url}`) — not from the subscriber.

### Cancel a scheduled send {#cancel}

**POST** `/api/v1/notifications/:id/cancel` — stops a send that is still `"scheduled"` (a future `sendAt`, or one held for quiet hours) or `"held_for_review"`. 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" }`.

```bash
curl -X POST https://relaybell.com/api/v1/notifications/5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42/cancel \
  -H "Authorization: Bearer rb_live_xxxxx"
```

## Send to one user

Section: Sending · Web page: https://relaybell.com/docs/send-to-one-user · Markdown: https://relaybell.com/docs/send-to-one-user.md

> Transactional sends to one of your own users by externalId, with examples in cURL, Node.js, Python and PHP.

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](https://relaybell.com/docs/identity)). Then send with that id from your server. If no subscribed browser has the id, the send still answers `202` and simply reaches nobody.

```bash tab="cURL"
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"
  }'
```

```js tab="Node.js"
// 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 tab="Python"
# 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 tab="PHP"
<?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.

## Drafts

Section: Sending · Web page: https://relaybell.com/docs/drafts · Markdown: https://relaybell.com/docs/drafts.md

> Save a notification as a draft with the API, change it as often as you like, then send it.

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](https://relaybell.com/docs/errors-limits#rate-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`](https://relaybell.com/docs/analytics-reports#notification-detail): its `sendBody` holds the saved fields. Drafts are listed with [`?status=draft`](https://relaybell.com/docs/analytics-reports#notifications-list).

```bash
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" }'
```

## Segments & topics

Section: Audience · Web page: https://relaybell.com/docs/segments-topics · Markdown: https://relaybell.com/docs/segments-topics.md

> The segment filter format and segment routes, topics, subscriber import/export and bulk labels.

### Segments {#segments}

`/api/v1/segments` — saved filters you can send to (`segmentId`), leave out of a send (`excludeSegmentId`), or attach to an RSS feed or a recurring schedule. A segment's `filters`, and the inline `filters` / `excludeFilters` of a send, use one format:

```json
{ "match": "all",                  // "all" = every rule must match (default), "any" = at least one
  "rules": [
    { "field": "country",  "op": "in", "value": ["IN", "US"] },
    { "field": "tag:plan", "op": "eq", "value": "pro" },
    { "field": "tag:vip",  "op": "exists" }
  ] }
```

| Part | Values |
| --- | --- |
| `field` | `country` (the 2-letter upper-case code from the visitor's IP), `language`, `url` (the subscriber's last page), `external_id` (or `externalId`), or `tag:KEY` for one of your tags. An unknown field matches **nobody** (the rule fails closed, so a typo can't turn a targeted send into a send to everyone). |
| `op` | `eq` (default; also used for an unknown op), `neq`, `contains` (case-insensitive), `in` (`value` is a list), `exists`, `not_exists` (no `value` needed). For a tag, `neq` also matches subscribers without that tag. |
| `value` | Compared as text, exactly: `eq`, `neq` and `in` are case-sensitive (only `contains` ignores case), so write country codes in upper case, e.g. `"IN"`. For `country`, `language`, `url` and `external_id`, `neq` doesn't match subscribers who have no value. |

| Request | What it does |
| --- | --- |
| **GET** `/api/v1/segments` | `{ segments: [{ id, name, filters, created_at }] }`, newest first. |
| **POST** `/api/v1/segments` | Body `{ name, filters }` → `201 { segment }`. `400 name_required` without a name. A segment needs at least one **complete** rule (a field — a `tag:` field with its key — and a value, unless the op is `exists` / `not_exists`): otherwise `400 segment_needs_a_rule`, because a segment with no rules would include every subscriber. |
| **PUT** `/api/v1/segments/:id` | Changes `name` and/or `filters` (the same rule check) → `{ segment }`. `400 nothing_to_update` when neither is sent; `404 not_found` for another website's id. |
| **DELETE** `/api/v1/segments/:id` | `{ ok: true }`. While an enabled RSS feed, an enabled recurring schedule, or a scheduled or queued send uses it (to include or to exclude), it answers `409 { "error": "segment_in_use", "feeds", "schedules", "pending" }` with how many of each. |
| **POST** `/api/v1/segments/preview` | Body `{ filters, topicId? }` → `{ count }`: how many subscribed **browsers** match right now (a person on two devices counts twice), optionally within a topic (id or slug). |
| **GET** `/api/v1/audience/segments` | Your saved segments with their size: `{ segments: [{ id, name, filters, created_at, count, lastUsedAt, sends, usedBy: { feeds, schedules } }] }`. `count` is the same number `/segments/preview` gives. Needs `subscribers:read`. |

Writes need `segments:write`. A send that names a segment which isn't in this website is refused (`400 segment_not_found`), and one whose segment is deleted before it goes out ends `failed` with `segment_missing` — a missing segment never means "everyone".

### Topics {#topics}

`/api/v1/topics` — preference-center categories. `GET` lists `{ id, name, slug, created_at, subscribers }`; `POST { name, slug? }` creates one (the slug is made from the name when left out; posting an existing slug renames that topic); `PUT /:id { name }` renames it (the slug never changes, since subscribers opt in by slug); `DELETE /:id`. Writes need `settings:write`. Subscribers opt in via `RelayBell.setTopics(["slug"])` / `showPreferences()`; target a send with `topicId` (id or slug).

### Subscribers {#subscribers}

- `GET /api/v1/subscribers/export` — CSV of subscribers. `GET /api/v1/subscribers/recent?limit=25` — the newest ones (up to 100).
- `POST /api/v1/subscribers/import` — body `{ subscribers: [{ externalId, tags: { … }, topics: ["slug"] }] }` (up to 10,000 rows) creates or enriches subscribers by external id → `{ ok, processed }` (`400 no_subscribers` for an empty list). Push subscriptions themselves can't be imported: each browser must opt in on your site.
- `GET /api/v1/subscribers/:externalId/profile` — one person (by external id or relaybell id): `{ subscriber, tags, endpoints, topics, sends, events, stats }`, with their last 50 deliveries/clicks and events; `/data` and `DELETE` are under [Compliance](https://relaybell.com/docs/compliance).
- `GET /api/v1/audience/subscribers` — the dashboard's paged, filterable list; `POST /api/v1/audience/subscribers/bulk-tag` `{ ids, set: { key: value }, remove: [key] }` (at most 20 labels at a time; `rb_` keys are reserved) and `/bulk-delete` `{ ids }` → `{ ok, deleted }`.

## Compliance / GDPR

Section: Audience · Web page: https://relaybell.com/docs/compliance · Markdown: https://relaybell.com/docs/compliance.md

> GDPR/CCPA: export or erase one subscriber, consent records, data retention.

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). The id is your `externalId`, or relaybell's subscriber id; an unknown one answers `404 not_found`.
- `DELETE /api/v1/subscribers/:externalId` — erase all of it (right to erasure): `{ ok: true, erased: true, subscriberId }`.
- Set a **retention window** (Settings → Privacy) to auto-prune old event logs. See our [Privacy Policy](https://relaybell.com/privacy) and [DPA](https://relaybell.com/dpa).

## Events & automation

Section: Automation · Web page: https://relaybell.com/docs/events-automation · Markdown: https://relaybell.com/docs/events-automation.md

> POST /api/v1/events, conversions and attribution, automations (conditions, delays, cancelOn) and auto-capture.

**POST** `/api/v1/events` — send a behavioral event from your server (scope `events:write`; the browser SDK's `track()` uses a public route instead). Name the person with `externalId` (your user id) or `subscriberId` (relaybell's id); an event that names nobody is stored but only a [product alert](https://relaybell.com/docs/product-alerts) can come of it.

```json
{ "event": "purchase", "externalId": "user_123", "properties": { "value": 79.99, "orderId": "A123" } }
```

It answers `202` (`400 event_required` without a name; `name` works as another spelling of `event`; names are cut to 120 characters):

```json
{ "ok": true, "eventId": "1234", "subscriberId": "…", "isConversion": true,
  "attribution": { "notificationId": "5b0c2f7e-…", "value": 79.99 },
  "automationsTriggered": 0, "sequencesEnrolled": 0 }
```

**Conversions:** `purchase`, `order.completed` and `checkout.completed` count as conversions (or the list in your `conversionEvents` setting), and so does any other event with a numeric `value` or `revenue` property — except the funnel steps `cart.updated` and `checkout.started`. A conversion is credited, with its value, to the last notification delivered to that subscriber in the previous 7 days (`attributionWindowDays` setting). `GET /api/v1/events` (scope `subscribers:read`) lists the latest 100 events.

### Automations {#automations}

`/api/v1/automations` — one push to a person when they do something: an event arrives, the optional delay passes, the message goes out. This powers cart abandonment (a `purchase` cancels the reminder), back-in-stock and price-drop (see [Product alerts](https://relaybell.com/docs/product-alerts)), and welcome-back flows. Each event fires every enabled automation with that `event` name whose `conditions` match.

```http
POST /api/v1/automations
{ "name": "Cart abandon", "event": "cart.updated", "delaySeconds": 3600, "cancelOn": "purchase",
  "conditions": [ { "field": "cartTotal", "op": "gte", "value": 20 } ],
  "action": { "title": "You left {product|something} in your cart 🛒", "body": "Complete your order before it's gone.", "url": "{url}" } }
```

| Field | Description |
| --- | --- |
| `event` \* | The event name that starts it (`400 event_required`). |
| `action` \* | `{ title, body, url, icon }` — `title` is required (`400 action_title_required`). Merge tags take values from the triggering event's properties: `{product}`, `{url}`, `{price\|}`, … |
| `name` | Up to 120 characters (default `"Automation"`). |
| `delaySeconds` | Wait before sending (default 0). With a delay, a newer event from the same person **replaces** their pending one, so three cart updates make one reminder, timed from the last. |
| `cancelOn` | An event name that cancels the person's pending (delayed) push, e.g. `purchase`. |
| `conditions` | A list of `{ field, op, value }` checked against the event's **properties**; all must match. `op`: `eq` (default), `neq`, `contains` (case-sensitive), `exists`, and the number comparisons `gt`, `gte`, `lt`, `lte`. Empty or left out: every event matches. |
| `oncePerSubscriber` | `true` = at most once per person, ever (a pending or sent one blocks the next). |
| `enabled` | Default `true`; `false` pauses it (pending pushes are dropped when they come due). |

`PUT /api/v1/automations/:id` replaces the automation with the body you send — `enabled` goes back to `true` unless you send `false` — except `conditions`, which are kept when the body has no `conditions` key. `GET /api/v1/automations` lists them with `sent` (all-time deliveries) and `stats30d` `{ sent, clicks, ctr, conversions, revenue }`; `GET /api/v1/automations/:id` returns one; `DELETE` removes one. Writes need `settings:write`. Automation pushes follow your quiet hours and frequency cap, and never go to someone who has unsubscribed.

### Auto-capture & Google Tag Manager {#auto-capture}

Rather than call `track()` by hand everywhere, opt in and relaybell records pageviews, declarative clicks and scroll depth for you — only for subscribed or identified visitors, so anonymous traffic makes no noise. The events are `page_view` `{ url, path, title }` (also on single-page-app route changes), your own `data-rb-event` names, and `scroll_depth` `{ percent }` at 25, 50, 75 and 100%:

```js
RelayBell.init({ projectId: "YOUR_PROJECT_ID", apiBase: "https://relaybell.com", autoCapture: true });
// granular: autoCapture: { pageviews: true, clicks: true, scroll: true }

// Track any click declaratively — the other data-rb-* attributes become event properties
// (data-rb-sku → sku, data-rb-unit-price → unit_price; values are text):
<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:

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

Section: Automation · Web page: https://relaybell.com/docs/product-alerts · Markdown: https://relaybell.com/docs/product-alerts.md

> Back-in-stock and price-drop alerts: the "Notify me" button, interest events and the broadcast to everyone interested.

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):

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

```bash
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 → New automation → **Back in stock** / **Price drop**, 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.join` for `productId` or `parentId`). 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 one `price_drop` alert per product (keyed by `parentId`, else `productId`) every 10 minutes — repeats inside that window send nothing.

For product alerts, `cancelOn` and `oncePerSubscriber` don't apply; `conditions` (checked against the event's properties), `delaySeconds` and your quiet hours still do. Unsubscribed people are never included, and one alert reaches at most 100,000 people. The `202` answer adds `productAlerts` (alerts created) and `alertAudience` (people reached).

## Drip sequences

Section: Automation · Web page: https://relaybell.com/docs/sequences · Markdown: https://relaybell.com/docs/sequences.md

> Drip sequences (journeys): subscribe and event triggers, message and branch steps, editing rules.

`/api/v1/sequences` — multi-step journeys (Automations → New automation → **Welcome series** or **Custom journey** in the dashboard). A subscriber enters when they subscribe (`"trigger": "subscribe"`, the default) or when they send an event (`"trigger": "event"` with `triggerEvent`), then moves through the steps in order; `cancelOn` takes them out when that event arrives (for example when they buy).

```http
POST /api/v1/sequences
{ "name": "Welcome series", "trigger": "subscribe", "cancelOn": "purchase",
  "steps": [ { "delaySeconds": 0, "title": "Welcome 👋", "url": "https://shop.example" },
             { "delaySeconds": 86400, "title": "Here's how to start" },
             { "type": "branch", "condition": { "field": "tag:plan", "op": "eq", "value": "free" } },
             { "delaySeconds": 604800, "title": "A little something for you" } ] }

POST /api/v1/sequences
{ "name": "After first order", "trigger": "event", "triggerEvent": "purchase",
  "steps": [ { "delaySeconds": 172800, "title": "How is your {product|order}?" } ] }
```

| Field | Description |
| --- | --- |
| `trigger`, `triggerEvent` | `"subscribe"` or `"event"`; with `"event"`, `triggerEvent` is required (`400 trigger_event_required`). |
| `steps` \* | At least one (`400 at_least_one_step_required`). A **message** step is `{ delaySeconds, title, body, url, icon }`; one without a `title` is dropped. A **branch** step is `{ type: "branch", delaySeconds, condition: { field, op, value } }`: the subscriber goes on only if they match the condition (the [segment rule format](https://relaybell.com/docs/segments-topics#segments): `country`, `language`, `url`, `external_id`, `tag:KEY`), otherwise their journey ends there. |
| `delaySeconds` | Per step: the wait after the previous step (for the first step, after entering). |
| `cancelOn`, `name`, `enabled` | As for automations. `enabled` defaults to `true`. |

A subscriber enters each journey **once**, ever — a second subscribe or trigger event doesn't start it again. Message-step merge tags take their values from the event that started the journey (a subscribe-triggered one has none, so use fallbacks). Journey pushes follow your quiet hours and frequency cap.

**Editing:** `PUT /api/v1/sequences/:id` always replaces `name`, `enabled`, `trigger` and `cancelOn` (send them all); `steps` are changed only when you send them. `GET /api/v1/sequences/:id` returns the steps with their `id`s, plus `enrollments` `{ active, completed, canceled }` and 30-day numbers per step. While nobody is in the journey you can reorder, add and remove steps (send a step's `id` to keep it and its numbers). While someone *is* in it, send every existing step back with its `id`, in the same order and with the same type — you may change its text, delay or condition — and only add new steps (without an `id`) at the end; anything else answers `409 structural_change_requires_no_active_enrollments`.

## REST API authentication

Section: Developers · Web page: https://relaybell.com/docs/authentication · Markdown: https://relaybell.com/docs/authentication.md

> Bearer API keys, scopes and which routes need them, creating and revoking keys, expiry.

All server-side calls use a Bearer API key (starts with `rb_live_`):

```bash
curl https://relaybell.com/api/v1/stats \
  -H "Authorization: Bearer rb_live_xxxxx"
```

Create keys in the dashboard under **Settings → Developers → API keys**. Signing up doesn't make a key for you, and a key can't make other keys: `POST /api/v1/keys` works only with a dashboard session (an API key gets `403 session_required`). 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).

An API key belongs to one website, so calls made with it need no project id. (The dashboard calls the same routes with its session cookie and the website's id in `?projectId=`, the body or an `X-Project-Id` header.)

Every endpoint, with the scope it needs, its parameters, responses, error codes and examples, is in the [API reference](https://relaybell.com/docs/api), generated from the OpenAPI 3.1 document at [/openapi.json](https://relaybell.com/openapi.json) (for code generators, Postman or an AI agent's tools).

### API keys & scopes {#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`, `POST /api/v1/upload`. |
| `events:write` | `POST /api/v1/events`. |
| `subscribers:read` | `GET /api/v1/subscribers/export`, `/subscribers/recent`, `/subscribers/:externalId/data` and `/profile`; `GET /api/v1/events`; `GET /api/v1/audience/subscribers`, `/audience/subscribers/facets` and `/audience/segments`. |
| `subscribers:write` | `POST /api/v1/subscribers/import`, `DELETE /api/v1/subscribers/:externalId`, `POST /api/v1/audience/subscribers/bulk-tag` and `/bulk-delete`. |
| `segments:write` | `POST`, `PUT` and `DELETE /api/v1/segments` (`POST /segments/preview` works with any key). |
| `settings:write` | `PUT /api/v1/settings`, `/prompt`, `/welcome`, `/logo`; `POST /reports/test`; changes to topics, automations, sequences (journeys), RSS feeds, recurring schedules, templates, goals and on-site messages. |
| `webhooks:write` | `POST`, `PUT` and `DELETE /api/v1/webhooks`, including `POST /webhooks/:id/test`, `/webhooks/:id/deliveries/:deliveryId/resend` and reading the delivery log (`GET /webhooks/:id/deliveries`). |

Reading works with any valid key: stats, reports, notifications, settings, segments, topics, automations, sequences, feeds, schedules, templates, goals, on-site messages, webhooks and their delivery logs (a key without `full` or `settings:write` doesn't get the Shopify signing secret back from `GET /api/v1/settings`). `GET` and `PUT /api/v1/identity` also work with any key (but only the dashboard can turn verification off — see [Identity verification](https://relaybell.com/docs/identity)). 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`; a revoked or unknown key, `401 invalid_api_key`.

| Route | Description |
| --- | --- |
| **POST** `/api/v1/keys` | Dashboard session only. Body `{ name?, scopes?, expiresAt? }`: `name` up to 60 characters (default `"API key"`); `scopes` a list from the table (default `["full"]`; a list with `full` becomes just `["full"]`; anything else answers `400 invalid_scopes`); `expiresAt` an ISO date in the future, within 3 years, or `null` for none (`400 invalid_expiry`). Answers `201 { apiKey, key }` — `apiKey` is the only time you see the key. |
| **GET** `/api/v1/keys` | `{ keys: [{ id, prefix, name, scopes, expires_at, created_at, last_used_at, expired, current }], scopes }` — never the keys themselves. Needs `full`. |
| **DELETE** `/api/v1/keys/:id` | Revokes a key: `{ ok: true }`. The signed-in owner can revoke any key; an API key can revoke only itself (`:id` = its own id or `current`), anything else answers `403 session_required`. |

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 or change your settings.

## Identity verification

Section: Developers · Web page: https://relaybell.com/docs/identity · Markdown: https://relaybell.com/docs/identity.md

> Identity verification: userHash = HMAC-SHA256 of the externalId with the website's secret key; /api/v1/identity.

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 → Developers → 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.

```js tab="Node.js"
// 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 tab="PHP"
<?php // PHP
$userHash = hash_hmac('sha256', (string) $externalId, $identitySecret);
// or, with the PHP SDK: Relaybell\Client::userHash($identitySecret, (string) $externalId)
```

```python tab="Python"
# 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:

```liquid
{{ customer.email | hmac_sha256: 'YOUR_SECRET_KEY' }}
```

### Pass it to the browser SDK

```js
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`. Turning it **on** works with an API key or the dashboard session; turning it **off** only from the dashboard — with an API key, `{ "required": false }` answers `403 identity_off_requires_dashboard`, so a leaked key (a WordPress site's, say) can't switch the protection off. |
| **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. |

```bash
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 }'
```

> **Note:** The secret key is never included in `/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`.

## Analytics & reports

Section: Developers · Web page: https://relaybell.com/docs/analytics-reports · Markdown: https://relaybell.com/docs/analytics-reports.md

> Stats, daily numbers, the notification list and detail, and CSV export.

### Stats {#stats}

**GET** `/api/v1/stats` — totals for the website, plus its 20 most recent sends (newest first):

```json
{
  "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`, `held_for_review`, `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 {#reports}

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 {#timeseries}

**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`).

```json
{
  "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 {#notifications-list}

**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`, `held_for_review`, `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.

```json
{
  "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`, `review_rejected` or `dispatch_error`), otherwise `null`. |

A bad filter answers `400` with `invalid_status`, `invalid_range`, `invalid_tz` or `invalid_cursor`.

#### One notification {#notification-detail}

**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`.

```json
{
  "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 {#export}

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

```bash
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

Section: Developers · Web page: https://relaybell.com/docs/webhooks · Markdown: https://relaybell.com/docs/webhooks.md

> Outbound webhooks: events, signatures and how to verify them, retries, the delivery log, resend, test events and auto-disable.

relaybell can tell your own server when something happens. Add a webhook in **Settings → Developers → Webhooks**, or with the API:

```bash
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 `url` must start with `http://` or `https://` (`400 invalid_url`, on `PUT` too); a webhook pointing at a private or internal address (`localhost`, `10.x`, `192.168.x` …) is saved, but every delivery to it is refused, its `last_status` shows `0` and its delivery log says why — to test on your own machine, expose the listener on a public HTTPS URL (a tunnel). The `secret` (starts with `whsec_`) is shown only in this answer — store it. An empty `events` list means every event; names that aren't in the table below are dropped, so a list of only unknown names also means every event. `GET /api/v1/webhooks` lists your webhooks with each one's `last_status` and `last_fired_at` (the last attempt) and its health: `consecutive_failures`, `failing_since`, `disabled_reason` and `disabled_at` (see [When a webhook keeps failing](https://relaybell.com/docs/webhooks#webhook-auto-disable)). `PUT /api/v1/webhooks/:id` changes `url`, `events` or `enabled`; `DELETE /api/v1/webhooks/:id` removes one, with its delivery log.

Each event is a `POST` with a JSON body and these headers:

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Relaybell-Event` | The event name, e.g. `notification.clicked`. |
| `X-Relaybell-Delivery` | The delivery id (a UUID): one per event and webhook, **the same on every retry and resend**. Keep the ones you've handled and skip repeats. |
| `X-Relaybell-Webhook-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` — the signature to check (see [Verify a webhook signature](https://relaybell.com/docs/webhooks#webhook-verify)). |
| `X-Relaybell-Signature` | `sha256=<hex HMAC-SHA256 of the body>` — the original signature, still sent unchanged so existing integrations keep working. It has no timestamp; new code should check `v1`. |

```json
{
  "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`. |
| `webhook.test` | Only sent when you ask for a test event (dashboard **Send test event**, or `POST /api/v1/webhooks/:id/test`), to that one webhook. `{ webhookId, message }` |

**Retries.** Answer within 10 seconds with any `2xx` status, and do slow work afterwards. Anything else — no answer in time, a connection or TLS error, any other status — is a failed attempt, and relaybell tries again up to 5 more times: about **1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours** after each failure (a little earlier or later, so retries don't all arrive at once) — roughly 10½ hours in all. Every attempt sends the same body and the same `X-Relaybell-Delivery`, with a new timestamp and signature. Answer `410 Gone` when the receiving end no longer exists (for example a deleted Zap): relaybell stops retrying and turns the webhook off, and the account owner gets an email. A `410` from a resend or a test event only ends that attempt. Redirects are followed (up to 4, never to a private address). Because of retries, the same event can arrive more than once, and later events can arrive first: skip a delivery id you've already handled, and use `at` for the time the event happened.

### Delivery log, resend and test events {#webhook-deliveries}

Every delivery and the result of its last attempt are kept for 30 days. In the dashboard: **Settings → Developers → Webhooks → Deliveries**. With the API, reading the log, resend and test need `webhooks:write` (the log holds event data such as subscriber ids), and together they are limited to 30 a minute per website (`429 rate_limited`). An id that isn't one of this website's answers `404 not_found`.

| Route | What it does |
| --- | --- |
| **GET** `/api/v1/webhooks/:id/deliveries?limit&cursor` | The webhook's deliveries, newest first, 25 a page (`limit` up to 100). For the next page, send the `nextCursor` you got as `cursor`; it is `null` on the last page (`400 invalid_cursor` for one this API didn't return). Answers `{ deliveries: [{ id, event, status, attempts, last_status_code, duration_ms, error, created_at, last_attempt_at, next_attempt_at, delivered_at }], nextCursor }`. |
| **GET** `/api/v1/webhooks/:id/deliveries/:deliveryId` | `{ delivery }` with everything above plus `request_body` (exactly what was signed and sent, up to 64 KB — `request_truncated` is `true` when it was longer), `request_headers` (the last attempt's headers, signatures included; never your secret) and `response_body` (the first 2 KB of your server's last answer). |
| **POST** `/api/v1/webhooks/:id/deliveries/:deliveryId/resend` | `202 { delivery }`: one more attempt now, with the same delivery id and body and a fresh timestamp and signature — no retries after it. `409 delivery_in_progress` while an attempt is already waiting to run; `409 body_too_large` for an event over 64 KB. |
| **POST** `/api/v1/webhooks/:id/test` | `202 { delivery }`: sends a `webhook.test` event to this webhook once, no retries — even when the webhook is turned off or doesn't subscribe to anything else. Follow its result with the delivery routes above. |

`status` is `pending` (waiting for its first attempt, or a resend or test waiting to run), `retrying` (an attempt failed; `next_attempt_at` says when the next one is), `succeeded` (the last attempt got a `2xx`) or `failed` (every attempt failed, your server answered `410`, or the webhook was turned off before the next try). `attempts` counts every attempt, resends included. `last_status_code` is `null` when no answer came back; `error` then says why, in plain words.

### When a webhook keeps failing {#webhook-auto-disable}

relaybell turns a webhook off when **50 deliveries in a row** have failed (all their attempts), or when **every attempt to reach it has failed for 3 days**. The account owner gets one email about it, and `GET /api/v1/webhooks` shows `enabled: false` with `disabled_reason` `"failed_50_in_a_row"` or `"failing_for_3_days"` and `disabled_at`. While it's off, no new events are sent to it and retries that were waiting stop. Until then, `failing_since` (the first failure since the last success) and `consecutive_failures` show how it's going; any success sets them back to `null` and `0`. A `410` answer, resends and test events never count toward turning it off (a success from one of them still clears the count). Once your server works again, turn the webhook back on in the dashboard or with `PUT /api/v1/webhooks/:id { "enabled": true }`, which clears the count. Events from while it was off aren't sent later.

### Verify a webhook signature {#webhook-verify}

`X-Relaybell-Webhook-Signature` is `t=<unix seconds>,v1=<signature>`. `v1` is the lowercase hex HMAC-SHA256 of `<t>.<raw body>` — the timestamp, a dot, then the exact bytes of the request body — keyed with your webhook secret: the whole string, `whsec_` prefix included, as text. To check a request:

1. Refuse to verify at all when your secret is missing or empty — fail closed, never with an empty key.
2. Split the header on commas into `key=value` pairs and take `t` and `v1`; ignore other keys (a later version may add one). A missing or malformed header fails the check.
3. Reject the request when `t` is more than **5 minutes** (300 seconds) away from your clock: a captured request can't be replayed later.
4. Compute the HMAC over the received bytes — not JSON you parsed and re-encoded — and compare it with `v1` in constant time.
5. Skip the event when you've already handled its `X-Relaybell-Delivery`.

```js tab="Node.js"
// Node.js (Express)
const crypto = require("crypto");

// The parsed event, or null when the request didn't come from relaybell.
function verifyRelaybell(rawBody, header, secret, toleranceSeconds = 300) {
  if (!secret) throw new Error("RELAYBELL_WEBHOOK_SECRET is not set"); // fail closed
  if (!Buffer.isBuffer(rawBody) || typeof header !== "string") return null;
  const fields = {};
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i > 0) fields[part.slice(0, i).trim()] = part.slice(i + 1).trim();
  }
  const { t, v1 } = fields;
  if (!/^\d+$/.test(t || "") || !/^[0-9a-f]{64}$/.test(v1 || "")) return null;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return null;
  const expected = crypto.createHmac("sha256", secret).update(t + ".").update(rawBody).digest();
  if (!crypto.timingSafeEqual(Buffer.from(v1, "hex"), expected)) return null; // both 32 bytes
  return JSON.parse(rawBody.toString("utf8"));
}

// express.raw keeps the exact bytes relaybell signed.
app.post("/webhooks/relaybell", express.raw({ type: "application/json" }), (req, res) => {
  const event = verifyRelaybell(req.body, req.get("X-Relaybell-Webhook-Signature"), process.env.RELAYBELL_WEBHOOK_SECRET);
  if (!event) return res.sendStatus(401);
  res.sendStatus(200);
  // …then handle event.event / event.data, once per X-Relaybell-Delivery
});
```

```python tab="Python"
# Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

app = Flask(__name__)

def verify_relaybell(raw_body: bytes, header: str, secret: str, tolerance: int = 300):
    """The parsed event, or None when the request didn't come from relaybell."""
    if not secret:
        raise RuntimeError("RELAYBELL_WEBHOOK_SECRET is not set")  # fail closed
    fields = {}
    for part in (header or "").split(","):
        key, sep, value = part.partition("=")
        if sep:
            fields[key.strip()] = value.strip()
    t, v1 = fields.get("t", ""), fields.get("v1", "")
    if not (t.isascii() and t.isdigit()) or abs(time.time() - int(t)) > tolerance:
        return None
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    # compare_digest is constant-time and returns False (no error) when the lengths differ.
    if not hmac.compare_digest(expected.encode(), v1.encode()):
        return None
    return json.loads(raw_body)

@app.post("/webhooks/relaybell")
def relaybell_webhook():
    event = verify_relaybell(
        request.get_data(),  # the exact bytes relaybell signed
        request.headers.get("X-Relaybell-Webhook-Signature", ""),
        os.environ.get("RELAYBELL_WEBHOOK_SECRET", ""),
    )
    if event is None:
        abort(401)
    return "", 200
```

```php tab="PHP"
<?php // PHP
$raw    = file_get_contents('php://input');               // the exact bytes relaybell signed
$header = $_SERVER['HTTP_X_RELAYBELL_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('RELAYBELL_WEBHOOK_SECRET');
if (!is_string($secret) || $secret === '') {              // never verify with an empty key
    http_response_code(500);
    exit;
}
$fields = [];
foreach (explode(',', $header) as $part) {
    $kv = explode('=', $part, 2);
    if (count($kv) === 2) {
        $fields[trim($kv[0])] = trim($kv[1]);
    }
}
$t  = $fields['t'] ?? '';
$v1 = $fields['v1'] ?? '';
if (!ctype_digit($t) || abs(time() - (int) $t) > 300) {   // 5-minute tolerance
    http_response_code(401);
    exit;
}
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!hash_equals($expected, $v1)) {                       // constant-time; false for any mismatch
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);                         // ['event' => ..., 'data' => [...]]
http_response_code(200);
```

The [server SDKs](https://relaybell.com/docs/server-sdks) (1.2.0 and newer) do all of this in one call: Node `verifyWebhook({ payload, headers, secret })`, Python `verify_webhook(payload, headers, secret)`, PHP `Relaybell\Webhook::verify($payload, $headers, $secret)`.

> **Note:** **The older header.** `X-Relaybell-Signature: sha256=<hex HMAC-SHA256 of the raw body>` (same secret) is still sent with every attempt, so code that checks it keeps working. It signs the body only, with no timestamp, so a captured request could be sent to you again later; move to `v1` when you can.

## Website settings & more routes

Section: Developers · Web page: https://relaybell.com/docs/website-settings · Markdown: https://relaybell.com/docs/website-settings.md

> GET and PUT /api/v1/settings for a website, plus the other API routes.

`GET /api/v1/settings` → `{ settings }`. `PUT /api/v1/settings` (scope `settings:write`) **merges** the keys you send into the stored ones (`{ "settings": { … } }` or the keys at the top level) and answers `{ ok: true, settings }`. The keys relaybell reads:

| Key | Meaning |
| --- | --- |
| `frequencyCap` | Most pushes one browser gets in 24 hours; `0` or unset = no cap. Transactional sends skip it. |
| `quietHours` | `{ enabled, start: "22:00", end: "08:00", timezone: "UTC" }` — sends that would go out in this window wait until it ends. |
| `defaultUrl` | The link used when a send has no `url` (`""` or `null` removes it). |
| `utm` | `{ enabled, source, medium, campaign }` — added to notification links (source defaults to `relaybell`, medium to `push`). |
| `timezone` | The website's time zone for reports, an IANA name (`400 invalid_timezone`). |
| `retentionDays` | Delete activity history older than this many days: a whole number 0–3650, 0 = keep forever (`400 invalid_retention_days`). |
| `privacyUrl` | Your privacy policy, linked from the opt-in prompt. |
| `conversionEvents`, `attributionWindowDays` | Which events count as conversions, and how many days after a delivery a conversion is credited to it (default 7). See [Events](https://relaybell.com/docs/events-automation). |
| `report` | `{ enabled, frequency: "weekly" \| "monthly" }` — the emailed performance report. `POST /api/v1/reports/test` sends one now. |
| `shopifySecret` | Your Shopify webhook signing secret (see [Shopify](https://relaybell.com/docs/install-shopify)). |

`identityVerification` is shown here but changed only with [`PUT /api/v1/identity`](https://relaybell.com/docs/identity).

### Other API routes {#more-api}

All of these take an API key (writes need the scope shown in [scopes](https://relaybell.com/docs/authentication#scopes)) and answer `404 not_found` for an id that isn't one of this website's.

| Route | What it does |
| --- | --- |
| `/api/v1/feeds` | **RSS → push.** `POST { url, segmentId? }` (`400 invalid_url`, `400 invalid_segment`); `PUT /:id { enabled?, segmentId? }`; `DELETE /:id`. New posts in the feed are sent automatically; feeds are checked about every 15 minutes. |
| `/api/v1/schedules` | **Recurring campaigns.** Body `{ name, content: { title, body, url, icon, segmentId \| filters, topicId, excludeSegmentId \| excludeFilters }, cadence, atTime, dayOfWeek, timezone, enabled }`: `cadence` `hourly` · `daily` (default) · `weekly`; `atTime` `"HH:MM"` (default `"09:00"`); `dayOfWeek` 0–6, 0 = Sunday (default 1); `timezone` an IANA name (default `UTC`; `400 invalid_timezone`). `400 content_title_required`, `400 unknown_topic`. |
| `/api/v1/templates` | Saved notification copy: `POST { name, content }` (`content` is any object, e.g. a send body), `PUT /:id`, `DELETE /:id`. |
| `/api/v1/goals` | Conversion goals: `POST { name, eventName }` (`400 name_and_event_required`; posting an existing name changes its event), `PUT /:id`, `DELETE /:id`; `GET /api/v1/goals/stats?days=30` → `{ windowDays, funnel: { delivered, clicked }, goals }`. |
| `/api/v1/onsite` | On-site banners and slide-ins (shown by the SDK to every visitor): `POST { title, body, ctaText, ctaUrl, style: "slidein" \| "banner", active }` (`400 title_required`), `PUT /:id`, `DELETE /:id`; `GET` includes `impressions` and `clicks`. |
| `PUT /api/v1/welcome` | The welcome push for new subscribers: `{ "welcome": { enabled, title, body, url, icon, image } }` — sent straight away to each new browser (it replaces the whole config). |
| `PUT /api/v1/logo` | The default notification icon: `{ "logo": "https://…" }` (`null` removes it). |
| `POST /api/v1/upload` | Upload an image for a notification (scope `send`): multipart form field `file`, PNG, JPEG, GIF or WebP up to 2 MB → `201 { url }`; anything else answers `400 invalid_file`. 60 uploads an hour. |
| `POST /api/v1/ai/suggest` | **Copy suggestions.** `{ title, body, goal? }` → `{ ai, suggestions: [{ title, body }, …] }`: three rewordings of your draft for you to edit — written by an AI model when `ai` is `true`, made from simple templates when it is `false`. 30 requests an hour. |

## Server SDKs

Section: Developers · Web page: https://relaybell.com/docs/server-sdks · Markdown: https://relaybell.com/docs/server-sdks.md

> The Node.js, Python and PHP server SDKs, including listing notifications and verifying webhooks.

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](https://relaybell.com/docs/quickstart).

| Language | Install |
| --- | --- |
| Node.js | `npm install relaybell` |
| Python | `pip install relaybell` |
| PHP | `composer require relaybell/relaybell-php` |

> **Note:** Prefer no package manager? Zipped builds are also available: [Node](https://relaybell.com/relaybell-node-sdk.zip) · [PHP](https://relaybell.com/relaybell-php-sdk.zip) · [Python](https://relaybell.com/relaybell-python-sdk.zip).

```js tab="Node.js"
// 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 tab="PHP"
<?php // PHP
$rb = new Relaybell\Client("rb_live_xxxxx");
$rb->send(["title" => "Hello", "body" => "World", "url" => "https://example.com"]);
```

```python tab="Python"
# 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`](https://relaybell.com/docs/identity) 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).

**Version 1.2.0** of all three adds reading your sends — Node and PHP `listNotifications(params)` and `getNotification(id)`, Python `list_notifications(**params)` and `get_notification(id)`, for [`GET /api/v1/notifications`](https://relaybell.com/docs/analytics-reports#notifications-list) (`from`, `to`, `status`, `q`, `limit`, `cursor`, `tz`) and `/:id` — and [webhook verification](https://relaybell.com/docs/webhooks#webhook-verify) in one call (last column). The Node SDK also ships TypeScript types, and throws if it is created in a browser, where your API key would be exposed.

| SDK | Options | Errors | Verify a webhook (1.2.0) |
| --- | --- | --- | --- |
| Node | `new RelayBell({ apiKey, baseUrl?, timeout? })`: `timeout` in ms (default 30000), `baseUrl` defaults to `https://relaybell.com`. | A non-2xx answer, network failure or timeout throws `RelayBellError` with `.status`, `.body` and `.code`. | `verifyWebhook({ payload, headers, secret, tolerance = 300 })`: `payload` is the raw body (string or Buffer), `headers` an object or `Headers`. Returns the parsed event; throws `RelayBellWebhookError` with `.code`. |
| Python | `RelayBell(api_key, base_url="https://relaybell.com", timeout=30.0)` (seconds). | Raises `relaybell.RelayBellError` with `.status` and `.body`. | `verify_webhook(payload: bytes, headers, secret, tolerance=300)`: returns the event as a dict; raises `relaybell.WebhookVerificationError` with `.code`. |
| PHP | `new Relaybell\Client($apiKey, $baseUrl = "https://relaybell.com", ["timeout" => 30, "connectTimeout" => …])` (seconds). | Throws `Relaybell\RelaybellException` (`getStatusCode()`). | `Relaybell\Webhook::verify($payload, $headers, $secret, $tolerance = 300)`: `$headers` may be `getallheaders()` or `$_SERVER`. Returns the event as an array; throws `Relaybell\WebhookVerificationException` (`getReason()`). |

Each verify function checks `X-Relaybell-Webhook-Signature` (the timestamp within `tolerance` seconds, the signature in constant time) and fails with one of these codes: `missing_secret` (an empty secret: it never verifies without one), `missing_signature`, `malformed_signature`, `timestamp_out_of_tolerance`, `signature_mismatch`, `invalid_payload`. The older `X-Relaybell-Signature` header is only accepted when you pass `allowLegacy: true` (Python `allow_legacy=True`, PHP a fifth argument `true`) and the request has no `v1` signature.

**Field names are the REST API's camelCase**: `externalId`, `externalIds`, `transactional`, `segmentId`, `sendAt`. Node and PHP pass fields through as they are, so a snake_case targeting field such as `external_id` is refused with `400 unknown_field`. The Python SDK 1.2.0 also takes snake_case keyword arguments and turns them into camelCase before sending — `external_id`, `external_ids`, `segment_id`, `exclude_segment_id`, `exclude_filters`, `topic_id`, `send_at`, `per_timezone`, `local_time`, `require_interaction`, `smart_time`, `ab_test` (with `auto_winner`, `test_percent`, `after_hours`) — and camelCase keeps working.

## Zapier

Section: Developers · Web page: https://relaybell.com/docs/zapier · Markdown: https://relaybell.com/docs/zapier.md

> Zapier actions and trigger (by invite).

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 → Developers → 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](https://relaybell.com/contact) for an invite link.

## AI assistants (MCP)

Section: Developers · Web page: https://relaybell.com/docs/ai-assistants · Markdown: https://relaybell.com/docs/ai-assistants.md

> Connect Claude, Cursor, VS Code or Windsurf to relaybell with the relaybell-mcp server, so your assistant can read your results and send notifications after you confirm.

`relaybell-mcp` is an [MCP](https://modelcontextprotocol.io) server: a small program your AI assistant runs on your computer to use relaybell. Once it's added, you can ask things like "How did last week's notifications do?" or "Draft a notification for the winter sale and send me a test", and the assistant looks up your real numbers and does the work.

It works with Claude Desktop, Claude Code, Cursor, VS Code (GitHub Copilot agent mode), Windsurf and any other assistant that supports MCP servers. It needs [Node.js](https://nodejs.org) 18 or newer, and an API key for the website you want the assistant to use (one key = one website).

> **Note:** Sending to your subscribers always takes two steps: the assistant shows you a summary of exactly what will go out and to how many devices, and sends only after you say yes. See [The two-step send](https://relaybell.com/docs/ai-assistants#two-step-send).

### Create a key {#create-a-key}

In the dashboard, open **Settings → Developers** and click **Create a key for your assistant**. It opens the usual create-key dialog with the name "AI assistant" and these permissions ticked:

| Permission | What the assistant uses it for |
| --- | --- |
| Send notifications (`send`) | Send and cancel notifications |
| Edit segments (`segments:write`) | Save a new segment |
| Report events (`events:write`) | Report an event for one person |
| Read subscribers (`subscribers:read`) | Look up one person, recent events and segment sizes |

After you create it, the card fills the key into the setup for Claude Desktop, Claude Code and Cursor, so you can copy it straight away (VS Code asks for it instead). The key is shown only once: if you leave the page, create a new one.

Don't give the assistant a **Full access** key: it doesn't need one. To let it read but never send or change anything, create a key with only **Read subscribers** and add `"RELAYBELL_READ_ONLY": "1"` next to the key in the setup below.

> **Warning:** The setup holds your API key. Keep it in your own settings, not in a file you commit to a repository. Revoke a key any time in **Settings → Developers → API keys**.

### Add it to your assistant {#setup}

Replace `YOUR_API_KEY` with your key (the dashboard card does this for you).

#### Claude Desktop {#claude-desktop}

In Claude Desktop, open **Settings → Developer → Edit Config**. This opens `claude_desktop_config.json`. Add relaybell, save, then quit Claude Desktop completely and open it again.

```json
{
  "mcpServers": {
    "relaybell": {
      "command": "npx",
      "args": ["-y", "relaybell-mcp"],
      "env": {
        "RELAYBELL_API_KEY": "YOUR_API_KEY"
      }
    }
  }
}
```

#### Claude Code {#claude-code}

Run this once in a terminal. `--scope user` makes relaybell available in every project.

```bash
claude mcp add --env RELAYBELL_API_KEY=YOUR_API_KEY --transport stdio --scope user relaybell -- npx -y relaybell-mcp
```

On Windows (not WSL), start npx through `cmd /c`: end the command with `-- cmd /c npx -y relaybell-mcp`. Check that it's connected with `claude mcp list`, or `/mcp` inside Claude Code. Claude Code asks you before every `confirm_send`, even when other tools run without asking.

#### Cursor {#cursor}

Put this in `~/.cursor/mcp.json` in your home folder (every project), or `.cursor/mcp.json` in one project:

```json
{
  "mcpServers": {
    "relaybell": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "relaybell-mcp"],
      "env": {
        "RELAYBELL_API_KEY": "YOUR_API_KEY"
      }
    }
  }
}
```

In a project file, write `"RELAYBELL_API_KEY": "${env:RELAYBELL_API_KEY}"` instead, so the key comes from your environment and never sits in the repository.

#### VS Code {#vscode}

Create `.vscode/mcp.json`. VS Code asks for the key the first time it starts relaybell and stores it securely, so the file never contains it:

```json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "relaybell-api-key",
      "description": "relaybell API key",
      "password": true
    }
  ],
  "servers": {
    "relaybell": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "relaybell-mcp"],
      "env": {
        "RELAYBELL_API_KEY": "${input:relaybell-api-key}"
      }
    }
  }
}
```

Then use Copilot Chat in agent mode.

#### Windsurf {#windsurf}

In Cascade, open the MCP servers panel and choose **View raw config** to open `mcp_config.json`. Add the same `relaybell` block as for [Claude Desktop](https://relaybell.com/docs/ai-assistants#claude-desktop) under `"mcpServers"`, save, and refresh the MCP panel.

#### Other assistants {#other}

Any assistant that runs MCP servers over stdio works: the command is `npx`, the arguments are `-y relaybell-mcp`, and the key goes in the `RELAYBELL_API_KEY` environment variable.

### Try it {#try-it}

- "How are my notifications doing this month compared with last month?"
- "Which of my last 20 notifications had the best click rate, and what do they have in common?"
- "How many subscribers are in the US and have the label plan = pro?"
- "Draft a notification for the winter sale for my Returning buyers segment, send me a test, then schedule it for Friday at 9:00 New York time."
- "Why did yesterday's notification get so few clicks?"

relaybell-mcp also comes with two ready-made prompts, **draft_campaign** and **why_did_this_campaign_underperform**. In Claude Code they appear as `/relaybell:draft_campaign`.

### What the assistant can do {#tools}

#### Read {#read-tools}

These never change anything.

| Tool | What it does |
| --- | --- |
| `get_project` | The website: name, address, subscribed devices, whether the code is installed, whether the account email is verified, and the sending rules. |
| `get_stats` | Sent, delivered, clicks, click-through rate, conversions and revenue for a date range, compared with the period before. |
| `list_segments` | Your segments and how many devices each matches now. |
| `preview_audience` | How many devices a segment, filters, a topic or a list of your user ids reach, with exclusions. |
| `list_topics` | Your topics and how many subscribers each has. |
| `list_automations` | Your automations and journeys, with their last-30-day results. It can't edit them. |
| `list_notifications` | Your notifications with their results, filtered by status, dates or words. |
| `get_notification` | One notification in detail: the funnel, devices, A/B variants, languages and delivery errors. |
| `get_subscriber` | One person's profile, looked up by the user id your site gave relaybell. |
| `list_recent_events` | The latest events your site or server reported. |

The assistant can also read the whole relaybell documentation and a list of every API endpoint.

#### Act {#write-tools}

| Tool | What it does |
| --- | --- |
| `send_test_to_my_devices` | Sends a notification to your own test devices only, never to subscribers. Add a test device in the dashboard first: on the Send page, click the arrow next to **Send test**. |
| `create_segment` | Saves a new segment. It can't change or delete existing ones. |
| `track_event` | Reports an event (such as signup or purchase) for one person. It can start that person's automations, and a purchase with a value counts as revenue. |
| `cancel_notification` | Stops a scheduled notification before it goes out. Sent notifications can't be recalled. |
| `prepare_send` | Step 1 of a send: checks it and returns a summary. Sends nothing. |
| `confirm_send` | Step 2: sends exactly what was prepared, after you say yes. |

#### What it can't do {#limits}

It can't create or revoke API keys, delete anything (subscribers, segments, notifications), change settings, rotate your identity-verification secret, or manage webhooks. The key's permissions apply on top: a key without **Send notifications** can't send, whatever the assistant tries.

### The two-step send {#two-step-send}

1. **Prepare.** `prepare_send` checks the title (up to 250 characters), the message, the link and image (full `https://` addresses), the audience and the timing: right away, at a set date and time (with a time zone, at most 90 days ahead), or at a time of day in each subscriber's own time zone. It counts how many devices the audience reaches now, and returns a plain summary and a confirmation token. Nothing is sent.
2. **You check the summary.** The assistant shows you the title, message, link, audience size and timing, and asks you to confirm.
3. **Confirm.** Only after your yes does the assistant call `confirm_send`, with the token and nothing else. It sends exactly what was prepared.

The confirmation token protects you from mistakes:

- It is tied to that exact send with a signature only this run of relaybell-mcp can make. A changed or made-up token is refused, and so is one from before the assistant restarted.
- It works once, and expires after 10 minutes.
- When it's used, the audience is counted again. If it changed by more than 20% (for example, a segment's rules were edited), nothing is sent and the assistant tells you why. Run the prepare step again to see the new numbers.
- A send with no audience is refused. To send to every subscriber, you have to say so, and the summary marks it as going to **every subscriber**. A segment without rules counts as everyone.

The usual send rules still apply: quiet hours, the hourly send limit, and the first-send review for new accounts. See [Errors and limits](https://relaybell.com/docs/errors-limits).

### Settings {#settings}

| Variable | Default | What it does |
| --- | --- | --- |
| `RELAYBELL_API_KEY` | required | Your API key. |
| `RELAYBELL_READ_ONLY` | off | `1` offers only the tools that read. |
| `RELAYBELL_BASE_URL` | `https://relaybell.com` | Only for a self-hosted or local relaybell. |

### Troubleshooting {#troubleshooting}

- **"RELAYBELL_API_KEY is not set".** The key isn't in the setup's `env` (or `--env` for Claude Code). Add it and restart the assistant.
- **"relaybell didn't accept this API key".** The key was revoked, has expired or was copied incompletely. Create a new one in **Settings → Developers**.
- **"This API key can't do that: it needs the 'send' permission".** Create a key with the [permissions above](https://relaybell.com/docs/ai-assistants#create-a-key).
- **"This relaybell account has not verified its email address yet".** Click the link in the verification email (check spam), or resend it from the banner in the dashboard. Test sends work meanwhile.
- **"Too many requests".** A key can make 60 requests a minute and 600 an hour, and each website has an hourly send limit. Wait a moment and try again.
- **"You don't have a test device yet".** In the dashboard, open **Send**, click the arrow next to **Send test** (Test devices), then open the private link on your own phone or computer and click **Allow**.
- **It doesn't start on Windows, or says "Connection closed".** Some assistants can't start `npx` directly on Windows. Use `"command": "cmd"` with `"args": ["/c", "npx", "-y", "relaybell-mcp"]`. If `npx` isn't found, install Node.js 18 or newer and restart the assistant.
- **Claude Desktop shows nothing.** Quit it completely (also from the system tray on Windows) and open it again.

## Errors & rate limits

Section: Reference · Web page: https://relaybell.com/docs/errors-limits · Markdown: https://relaybell.com/docs/errors-limits.md

> Every error code with its HTTP status, plus the request and send rate limits.

Errors return a JSON body `{ "error": "code", "request_id": "…" }` (often with a readable `message`, and sometimes more fields) and a standard HTTP status. The same `request_id` comes back in the `X-Request-Id` header of every response — quote it when you contact support. Branch on `error`, not on `message`, which may change.

| Status | Code | When |
| --- | --- | --- |
| `400` | `invalid_json` | The body isn't valid JSON (send `Content-Type: application/json`). |
| `400` | `title_required`, `external_ids_empty`, `segment_not_found`, `unknown_topic` | A send (or a draft being sent): no title; `externalId` / `externalIds` given but empty; a segment that isn't this website's; an unknown topic. |
| `400` | `segment_needs_a_rule`, `name_required`, `nothing_to_update` | Segments — see [Segments](https://relaybell.com/docs/segments-topics#segments) — and topics or templates without a name. |
| `400` | `event_required`, `action_title_required` | An event or automation without an event name; an automation without `action.title`. |
| `400` | `trigger_event_required`, `at_least_one_step_required` | Sequences (journeys). |
| `400` | `content_title_required`, `invalid_timezone`, `invalid_url`, `invalid_segment`, `name_and_event_required` | Recurring schedules and website settings, webhooks and feeds, goals. |
| `400` | `invalid_settings`, `invalid_prompt`, `invalid_welcome`, `invalid_color`, `invalid_retention_days` | Settings, prompt and welcome bodies. |
| `400` | `invalid_required`, `invalid_scopes`, `invalid_expiry` | `PUT /identity`; creating an API key. |
| `400` | `invalid_range`, `invalid_tz`, `invalid_status`, `invalid_cursor` | Report and list filters. |
| `400` | `ids_required`, `too_many_ids`, `invalid_id`, `invalid_label`, `reserved_label`, `too_many_labels`, `nothing_to_change`, `invalid_filters`, `invalid_device` | The `/api/v1/audience` routes. |
| `400` | `invalid_file`, `no_subscribers`, `no_account_email` | An upload that isn't an image; an empty import; `POST /reports/test` without an account email. |
| `400` | `unknown_field` | A send field written in snake_case (`external_id`, `segment_id`, `send_at` …). It would be ignored and the send would reach everyone, so it is refused; `use` names the camelCase field. |
| `401` | `not_authenticated`, `invalid_api_key`, `api_key_expired` | No key sent (and no dashboard session); a key that isn't recognised or was revoked; a key past its expiry date. |
| `403` | `insufficient_scope` | The key lacks the scope named in `required` (see [scopes](https://relaybell.com/docs/authentication#scopes)). |
| `403` | `email_not_verified` | Sends are refused until the account's email address is verified. |
| `403` | `account_suspended` | The account is suspended. |
| `403` | `session_required`, `identity_off_requires_dashboard` | Only the signed-in owner can do this: create keys, revoke another key, rotate the identity secret, turn identity verification off. |
| `404` | `not_found` | The id isn't one of this website's — including any id that isn't a valid UUID. |
| `404` | `not_scheduled`, `project_not_found`, `subscriber_not_found` | Cancelling a send that isn't waiting; a public SDK route with an unknown Project ID; topic preferences for a browser that isn't subscribed. |
| `409` | `not_a_draft`, `too_many_drafts` | Only a draft can be changed, sent or deleted that way; a website keeps up to 500 drafts. |
| `409` | `segment_in_use` | Deleting a segment that feeds, schedules or pending sends still use. |
| `409` | `structural_change_requires_no_active_enrollments` | Reordering or removing journey steps while people are in the journey. |
| `409` | `name_taken` | Renaming a goal to the name of another goal. |
| `409` | `delivery_in_progress`, `body_too_large` | Resending a webhook delivery that already has an attempt waiting, or whose event was over 64 KB. |
| `413` | `draft_too_large`, `payload_too_large`, `file_too_large` | A draft holds up to 256 KB, a request body up to 1 MB, an uploaded image up to 2 MB. |
| `429` | `rate_limited`, `send_limit_reached` | Too many requests, or 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. |

Every `/api/v1` route checks the credential first, so an unknown path answers `401 not_authenticated` without a key (and a plain `404` page with one). Request bodies can be up to 1 MB.

### Rate limits {#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` / `GET /api/v1/events` with an API key | 3,000 a minute | API key |
| Browser SDK requests (`/api/v1/projects/…`: config, subscribe, `track()`, inbox, preferences; click and delivery pings), and `/api/v1/events` without a key | 120 a minute | visitor IP address |
| Sign-up, sign-in (including two-factor and Google/GitHub sign-in), email verification, password reset; changing the account's password or email; turning two-factor on or off; deleting or restoring the account | 30 per 15 minutes | IP address |
| Other dashboard account routes (`/api/v1/auth/me`, `/api/v1/account`, …) | 120 a minute | IP address |
| `POST /api/v1/ai/suggest` | 30 an hour | IP address |
| `POST /api/v1/upload` | 60 an hour | IP address |
| `POST /api/v1/support` | 5 an hour | account |

Other API-key routes (stats, reports, segments, automations, settings, …) have no request limit of their own; keep to a sensible pace.

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.

## Account & support

Section: Reference · Web page: https://relaybell.com/docs/account · Markdown: https://relaybell.com/docs/account.md

> Dashboard-only account and support endpoints.

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

> **Note:** **White-label:** relaybell's prompts, service worker, and notifications carry no relaybell branding — they show only your copy and icon. You can host the SDK/SW under your own domain via a CNAME.

## Browser support

Section: Reference · Web page: https://relaybell.com/docs/browser-support · Markdown: https://relaybell.com/docs/browser-support.md

> Supported browsers, and what iPhone/iPad need.

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.

## Changelog

Section: Reference · Web page: https://relaybell.com/docs/changelog · Markdown: https://relaybell.com/docs/changelog.md

> What changed for developers: the docs in the new site and relaybell for AI assistants; webhooks v2, server SDKs 1.2.0 and the OpenAPI reference; WordPress plugin 1.2.5.

Changes that matter when you build on relaybell, newest first. Changes inside the dashboard are in the [setup guide](https://relaybell.com/guide).

### 2026-10-21: docs in the new site, and relaybell for AI assistants {#2026-10-21}

**Developer docs**

- The docs are now one page per topic at [/docs](https://relaybell.com/docs), with search (Ctrl/⌘ K), an "On this page" outline and a **Copy page** menu (copy as Markdown, or open the page in ChatGPT or Claude).
- New install guides for [Next.js](https://relaybell.com/docs/install-nextjs), [React](https://relaybell.com/docs/install-react) and [Vue and Nuxt](https://relaybell.com/docs/install-vue).
- Every page has a Markdown copy at `/docs/<page>.md`, and [/llms.txt](https://relaybell.com/llms.txt) lists them all. Old links such as `/docs#webhooks` still land on the right page.

**AI assistants (MCP)**

- `relaybell-mcp` lets Claude Desktop, Claude Code, Cursor and VS Code read your results, segments and topics, draft a notification, send a test to your own devices and, after you confirm a summary, send it. It can't delete anything, create keys or change settings. Set it up from **Settings → Developers → Use relaybell from your AI assistant**, or see [AI assistants](https://relaybell.com/docs/ai-assistants).

### 2026-10-20: webhooks v2, server SDKs 1.2.0 and the API reference {#2026-10-20}

**Webhooks**

- A new signature header, `X-Relaybell-Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>`, signs the timestamp and the raw body together, so an old request can't be replayed. The older `X-Relaybell-Signature` header is still sent unchanged, so existing receivers keep working. See [Verify a webhook signature](https://relaybell.com/docs/webhooks#webhook-verify).
- New headers `X-Relaybell-Event` (the event name) and `X-Relaybell-Delivery` (the delivery id, the same on every retry and resend: de-duplicate by it).
- Failed deliveries are retried 5 times over about 10 hours. A `410 Gone` answer means the receiving end no longer exists (for example a deleted Zap): retries stop and the webhook is turned off, and the account owner is emailed.
- A 30-day delivery log, resend and test events: `GET /api/v1/webhooks/:id/deliveries` and `POST /api/v1/webhooks/:id/test`. See [Delivery log, resend and test events](https://relaybell.com/docs/webhooks#webhook-deliveries).
- A webhook that keeps failing (3 days, or 50 deliveries in a row) is turned off. See [When a webhook keeps failing](https://relaybell.com/docs/webhooks#webhook-auto-disable).

**Server SDKs 1.2.0** (Node.js on npm as `relaybell`, PHP on Packagist as `relaybell/relaybell-php`; the Python 1.2.0 build is in the [zip download](https://relaybell.com/relaybell-python-sdk.zip))

- Webhook verification in one call: Node `verifyWebhook()`, Python `verify_webhook()`, PHP `Relaybell\Webhook::verify()`.
- Reading your sends: `listNotifications()` and `getNotification()` (Python `list_notifications()` and `get_notification()`).
- Node: TypeScript types, and an error when the client is created in a browser, where your API key would be exposed.
- Python: snake_case keyword arguments (`external_id`, `segment_id`, `send_at`, …) are turned into the API's camelCase fields.
- Details: [Server SDKs](https://relaybell.com/docs/server-sdks).

**API reference**

- The REST API as an OpenAPI 3.1 document at [/openapi.json](https://relaybell.com/openapi.json): every customer endpoint, with the API key scope each one needs (`x-scope`).
- [API reference](https://relaybell.com/docs/api): every endpoint with its scope, parameters, request body, responses, error codes and code samples, generated from that document.
- [/llms-full.txt](https://relaybell.com/llms-full.txt) now ends with a list of every endpoint and the scope it needs.

### 2026-10-19: WordPress plugin 1.2.5 {#2026-10-19-wordpress}

The version submitted to the wordpress.org plugin directory. Mostly changes asked for by its review rules:

- The settings page's styles and scripts are regular files loaded through WordPress's script and style queues; the page works as before.
- The "Notify me when back in stock" button's markup is escaped when it is printed.
- The relaybell host under Advanced must be an `https://` address.
- The clean-up when you delete the plugin uses prepared database queries; it removes the same data as before.
- The status panel says "1 subscriber" instead of "1 subscribers".

Version 1.2.4 added [identity verification](https://relaybell.com/docs/identity) for logged-in visitors. See [WordPress & WooCommerce](https://relaybell.com/docs/install-wordpress).

### 2026-10-19: docs for AI assistants {#2026-10-19-docs}

- [/llms-full.txt](https://relaybell.com/llms-full.txt): everything an AI assistant needs in one text file (key facts, the developer docs, the setup guide and the plans). [/llms.txt](https://relaybell.com/llms.txt), the shorter index, is built from the same facts.
- The developer docs as Markdown: [/docs.md](https://relaybell.com/docs.md), and one file per section.
- The developer docs were checked against the code and corrected.
