Creator Notify v1.0.0

Integration guides

Two separate jobs: collecting subscriptions in the browser (public identifier, no secrets) and sending notifications from a server (secret key, never in the browser). Every example below works as written; the full files live in examples/ in the repository.

The one rule

The secret API key belongs on a server you control. Not in HTML, not in a public JavaScript file, not in a Git repository, not in localStorage. If a key ever reaches the browser, rotate it immediately with POST /v1/sites/rotate-key.

Plain HTML

The minimum viable integration: a button, the SDK, and a status line. Copy sdk/creator-notify.js and sdk/service-worker.js to your web root first (Service Worker guide).

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Example site</title>
</head>
<body>
  <h1>Example site</h1>

  <button id="enable" type="button">Enable notifications</button>
  <p id="status" role="status" aria-live="polite"></p>

  <script src="/creator-notify.js"></script>
  <script>
    const notify = new CreatorNotify({
      websiteId: 'pub_8d1f0c...',
      apiBaseUrl: 'https://YOUR-API-HOST',
      serviceWorkerPath: '/service-worker.js'
    }).init();

    const button = document.getElementById('enable');
    const status = document.getElementById('status');

    if (!notify.isSupported()) {
      button.disabled = true;
      status.textContent = 'This browser does not support push notifications.';
    }

    button.addEventListener('click', async () => {
      button.disabled = true;
      status.textContent = 'Requesting permission…';
      try {
        await notify.subscribe();
        status.textContent = 'Subscribed. Thanks!';
        button.textContent = 'Notifications enabled';
      } catch (error) {
        status.textContent = `Could not subscribe (${error.code}): ${error.message}`;
        button.disabled = false;
      }
    });
  </script>
</body>
</html>

JavaScript admin panel

The browser form never sees the secret key. It posts the message to your endpoint; your server adds the key and calls Creator Notify. The snippet assumes an Express-style handler at /api/send.

<form id="notify-form">
  <label>Title <input name="title" maxlength="160" required></label>
  <label>Body <textarea name="body" maxlength="1200"></textarea>
  <label>Destination <input name="url" type="url" placeholder="https://example.com/offers"></label>
  <button type="submit">Send notification</button>
  <p id="result" role="status"></p>
</form>

<script>
  const form = document.getElementById('notify-form');
  const result = document.getElementById('result');

  form.addEventListener('submit', async (event) => {
    event.preventDefault();
    result.textContent = 'Sending…';

    const payload = Object.fromEntries(new FormData(form).entries());

    try {
      const response = await fetch('/api/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify(payload)
      });
      const body = await response.json();

      if (!response.ok || body.success === false) {
        result.textContent = `Failed: ${body.error?.code || response.status} ${body.error?.message || ''}`;
        return;
      }

      result.textContent =
        `Accepted ${body.accepted_subscriptions}, succeeded ${body.successful_submissions}, ` +
        `failed ${body.failed_submissions}, invalid ${body.invalid_subscriptions}.` +
        (body.warnings?.length ? ` Warnings: ${body.warnings.join(', ')}` : '');
    } catch (error) {
      result.textContent = `Network error: ${error.message}`;
    }
  });
</script>

Server side (Node.js, Express):

// server.mjs — the key comes from the environment, never from the request
import express from 'express';

const app = express();
app.use(express.json());
app.use(express.static('public'));

app.post('/api/send', async (req, res) => {
  const { title, body, url } = req.body;

  if (!title || typeof title !== 'string') {
    return res.status(400).json({ success: false, error: { code: 'INVALID_REQUEST', message: 'title is required' } });
  }

  const response = await fetch(`${process.env.CREATOR_NOTIFY_URL}/v1/notifications`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CREATOR_NOTIFY_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ title, body, url: url || undefined })
  });

  const payload = await response.json().catch(() => ({}));
  return res.status(response.status).json(payload);
});

app.listen(3000);

PHP

Full working file: examples/php/send-notification.php. It reads the key from an environment variable, validates the response and never echoes internals.

<?php
declare(strict_types=1);

$apiBase = getenv('CREATOR_NOTIFY_URL') ?: 'https://YOUR-API-HOST';
$apiKey  = getenv('CREATOR_NOTIFY_KEY') ?: '';

if ($apiKey === '') {
    http_response_code(500);
    error_log('CREATOR_NOTIFY_KEY is not configured');
    exit('Notification service is not configured.');
}

$payload = [
    'title' => 'New article published',
    'body'  => 'A new post is available on the blog.',
    'url'   => 'https://example.com/blog/new-post',
    'tag'   => 'blog-post',
    'data'  => ['post_id' => 42],
];

$ch = curl_init($apiBase . '/v1/notifications');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 15,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Accept: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_SLASHES),
]);

$raw      = curl_exec($ch);
$status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr  = curl_error($ch);
curl_close($ch);

if ($raw === false) {
    error_log('Creator Notify transport error: ' . $curlErr);
    http_response_code(502);
    exit('Could not reach the notification service.');
}

$response = json_decode($raw, true);
if (!is_array($response)) {
    error_log('Creator Notify returned a non-JSON response');
    http_response_code(502);
    exit('Unexpected response from the notification service.');
}

if ($status >= 400 || ($response['success'] ?? false) !== true) {
    error_log('Creator Notify error: ' . ($response['error']['code'] ?? 'unknown'));
    http_response_code(502);
    exit('The notification could not be published.');
}

printf(
    'Accepted %d, succeeded %d, failed %d, invalid %d',
    $response['accepted_subscriptions'] ?? 0,
    $response['successful_submissions'] ?? 0,
    $response['failed_submissions'] ?? 0,
    $response['invalid_subscriptions'] ?? 0
);

Environment (.env, never committed):

CREATOR_NOTIFY_URL=https://YOUR-API-HOST
CREATOR_NOTIFY_KEY=cn_live_REPLACE_WITH_YOUR_KEY

Existing admin panels

Most admin panels already have a controller that handles "publish post" or "save product". Add one outbound call after the database write, and make it non-fatal:

  1. Read the API base URL and key from configuration (environment, secrets manager, config.php outside the web root).
  2. Build the smallest payload that makes sense: title, body, url, and a stable tag.
  3. Send with a short timeout (5–15 s) and log the request_id on failure.
  4. Never let a notification failure roll back the user's content write.
  5. If the response is SITE_EXPIRED, alert the operator — the registration needs POST /v1/sites/reactivate.

Custom backends

Anything that can make an HTTPS request works. The contract is: POST /v1/notifications, Authorization: Bearer <key>, Content-Type: application/json, JSON body.

# Python (requests)
import os, requests

response = requests.post(
    f"{os.environ['CREATOR_NOTIFY_URL']}/v1/notifications",
    headers={
        "Authorization": f"Bearer {os.environ['CREATOR_NOTIFY_KEY']}",
        "Content-Type": "application/json",
    },
    json={"title": "Deploy finished", "body": "Build 142 is live.", "url": "https://example.com/status"},
    timeout=15,
)
response.raise_for_status()
print(response.json()["successful_submissions"])

# Go (net/http)
# req, _ := http.NewRequest("POST", apiURL+"/v1/notifications", body)
# req.Header.Set("Authorization", "Bearer "+apiKey)
# req.Header.Set("Content-Type", "application/json")

Relaying subscriptions through your backend

Collective sites sometimes proxy the subscription call so they can attach their own user id. Authenticate that call with the secret key instead of the public identifier, and keep the same body shape:

curl -X POST https://YOUR-API-HOST/v1/subscriptions \
  -H "Authorization: Bearer $CREATOR_NOTIFY_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
        "subscription": {
          "endpoint": "https://fcm.googleapis.com/fcm/send/XXXX",
          "keys": { "p256dh": "BOr...", "auth": "k9Q..." }
        }
      }'

In this mode no Origin header is required — the key already proves the tenant. Do not forward raw browser subscriptions from untrusted clients without your own abuse controls.

Integration checklist

  • Service Worker served from your origin at a path that covers the pages you care about.
  • Permission requested inside a click handler, not on load.
  • Public identifier in the page; secret key only in server configuration.
  • Publishing at least once every seven days, or a scheduled reminder.
  • Error handling that shows the API's error.code rather than a generic failure.
  • An alert when the API returns SITE_EXPIRED or SITE_SUSPENDED.