Creator Notify v1.0.0

API reference

Base URL: https://YOUR-API-HOST. All request and response bodies are JSON unless stated otherwise. Timestamps are ISO-8601 UTC. Every response includes an x-request-id header.

Conventions

ItemValue
Success envelope{ "success": true, ... }
Error envelope{ "success": false, "error": { "code", "message", "details" }, "request_id" }
Auth headerAuthorization: Bearer cn_live_...
Content typeContent-Type: application/json (required for JSON bodies)
CORSOnly on browser-facing routes, only for registered origins. Never on management routes.
Machine-readable indexGET /v1

Rate limits

Limits are fixed windows stored in the service database, so they are shared by every instance handling your traffic. Exceeding one returns 429 with a retry-after header and RATE_LIMIT_EXCEEDED. Every response also carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset.

ScopeApplies toDefaultVariable
registerPOST /v1/sites/register, per client IP10 / hourRATE_LIMIT_REGISTER_PER_HOUR
subscribeSubscription creation + config, per site and per IP60 / minuteRATE_LIMIT_SUBSCRIBE_PER_MINUTE
unsubscribeDELETE /v1/subscriptions60 / minuteRATE_LIMIT_UNSUBSCRIBE_PER_MINUTE
publishPOST /v1/notifications, per site120 / minuteRATE_LIMIT_PUBLISH_PER_MINUTE
readStatus, lists, counts, key rotation300 / minuteRATE_LIMIT_READ_PER_MINUTE

GET /health

no auth Liveness plus a database probe. Returns 503 when the service cannot reach its database.

curl -s https://YOUR-API-HOST/health
{
  "success": true,
  "service": "creator-notify",
  "version": "1.0.0",
  "status": "ok",
  "environment": "production",
  "time": "2026-01-31T12:00:00.000Z",
  "checks": { "database": "ok" }
}

GET /v1

no auth Machine-readable index of endpoints, conventions and active limits. Useful for smoke tests and for agents that discover APIs at runtime.


POST /v1/sites/register

no auth rate limited Registers a website and returns its credentials.

POST/v1/sites/register

Body

FieldTypeRequiredNotes
namestringyes2–120 characters, used only for your own reference.
originstringyeshttps://example.com. Scheme + host + optional port; no path, query or credentials. Stored lower-cased and normalised.
contact_emailstringyesOperational contact, used only to reach the owner. Never published by any endpoint.

Optional gate

If the REGISTRATION_TOKEN secret is set, the request must carry a matching x-registration-token header. Leave the secret unset for public registration on a throwaway deployment; set it when you do not want strangers registering origins on your instance.

Example

curl -X POST https://YOUR-API-HOST/v1/sites/register \
  -H 'Content-Type: application/json' \
  -d '{"name":"My Site","contact_email":"you@example.com","origin":"https://example.com"}'

Repeated attempts

An address can be registered once. Every request for the same origin is counted, including ones that fail with DUPLICATE_SITE. Past REGISTRATION_ATTEMPT_LIMIT (4 by default) the website is blocked permanently and every call for it returns SITE_BLOCKED.

AttemptResponse
1201 with site and credentials
2 – 4409 DUPLICATE_SITE; details carries attempts and attempts_remaining
5+403 SITE_BLOCKED; permanent, and existing keys stop working

201 Created

{
  "success": true,
  "site": {
    "id": "site_9f2c1a...",
    "name": "My Site",
    "origin": "https://example.com",
    "public_identifier": "pub_8d1f0c...",
    "status": "active",
    "created_at": "2026-01-31T12:00:00.000Z",
    "last_triggered_at": null,
    "last_reactivated_at": null,
    "key_rotated_at": null,
    "key_version": 1,
    "api_key_prefix": "cn_live_5a2b1c4d…",
    "api_key_hint": "9x2q",
    "has_api_key": true,
    "expires_at": "2026-02-07T12:00:00.000Z",
    "expires_in_seconds": 604800,
    "inactivity_days": 7,
    "notification_count": 0
  },
  "credentials": {
    "public_identifier": "pub_8d1f0c...",
    "api_key": "cn_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "api_key_note": "Store this key in a server-side secret store. It cannot be retrieved again; rotate it if it is lost."
  }
}

Errors

CodeStatusCause
INVALID_REQUEST400Missing/invalid name or origin
DUPLICATE_SITE409The origin is already registered
REGISTRATION_DISABLED403REGISTRATION_ENABLED=false
INVALID_API_KEY401Missing/incorrect x-registration-token
RATE_LIMIT_EXCEEDED429More than 10 registrations per hour from one IP

GET /v1/sites/status

secret key Registration state, lifecycle timestamps and statistics for the authenticated site.

curl -s https://YOUR-API-HOST/v1/sites/status \
  -H "Authorization: Bearer $CREATOR_NOTIFY_KEY"
{
  "success": true,
  "site": { "...": "same shape as the register response, without credentials" },
  "statistics": {
    "subscriptions": { "total": 1432, "active": 1402, "invalid": 21, "unsubscribed": 9 },
    "notifications": {
      "total": 57,
      "delivered": 54,
      "push_submissions_succeeded": 78213,
      "push_submissions_failed": 941,
      "subscriptions_invalidated": 312,
      "last_notification_at": "2026-01-31T11:40:02.000Z"
    }
  },
  "lifecycle": {
    "inactivity_days": 7,
    "expires_at": "2026-02-07T11:40:02.000Z",
    "last_triggered_at": "2026-01-31T11:40:02.000Z",
    "enforcement": "checked on every authenticated request and by the scheduled job"
  }
}

push_submissions_* count per-subscription attempts; notifications.delivered counts notifications whose attempts all succeeded.


POST /v1/sites/reactivate

secret key Restarts the inactivity window. Permitted while expired — that is the whole point — but not while suspended.

curl -X POST https://YOUR-API-HOST/v1/sites/reactivate \
  -H "Authorization: Bearer $CREATOR_NOTIFY_KEY"
{
  "success": true,
  "site": { "...": "status is now 'active'" },
  "reactivated": true,
  "expires_at": "2026-02-07T12:30:00.000Z",
  "note": "Subscriptions were not touched. Notifications can be published again immediately."
}

Errors: SITE_SUSPENDED (403) when the key was revoked; INVALID_API_KEY (401) otherwise.


POST /v1/sites/rotate-key

secret key Mints a new secret key and immediately invalidates the old one.

curl -X POST https://YOUR-API-HOST/v1/sites/rotate-key \
  -H "Authorization: Bearer $CREATOR_NOTIFY_KEY"
{
  "success": true,
  "site": { "...": "key_version is now 2" },
  "credentials": {
    "api_key": "cn_live_NEWXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "key_version": 2,
    "api_key_note": "The previous key is invalid immediately. Update your server-side secret store now."
  }
}

No overlap window

Rotation is atomic: the moment this response is produced the old key stops authenticating. Deploy the new key to your server first if you need continuity — or accept a short gap.


POST /v1/sites/revoke-key

secret key Emergency stop. Clears the key hash and suspends the registration.

curl -X POST https://YOUR-API-HOST/v1/sites/revoke-key \
  -H "Authorization: Bearer $CREATOR_NOTIFY_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"confirm": true}'
{
  "success": true,
  "site": { "...": "status: 'suspended', has_api_key: false" },
  "revoked": true,
  "note": "The API key no longer works and the registration is suspended. Contact the operator, or re-register the origin, to resume."
}

A suspended registration cannot publish, reactivate or rotate. Historical notifications and delivery attempts are retained. To resume, re-register the origin (POST /v1/sites/register) with the same operator token, or have the operator lift the suspension — both are operator actions by design.


GET /v1/subscriptions/config

no auth Bootstrap endpoint used by the SDK. Returns the deployment's VAPID public key.

curl -s "https://YOUR-API-HOST/v1/subscriptions/config?website_id=pub_8d1f0c..."
{
  "success": true,
  "website_id": "pub_8d1f0c...",
  "origin": "https://example.com",
  "vapid_public_key": "BMx... (65-byte base64url point)",
  "api_version": "v1",
  "endpoints": { "subscriptions": "/v1/subscriptions" }
}

If the request carries an Origin header it must match the registered origin. Errors: INVALID_REQUEST (missing website_id), SITE_NOT_FOUND, SITE_EXPIRED, SERVICE_UNAVAILABLE (VAPID not configured).


POST /v1/subscriptions

browser: website_id + Origin or secret key Creates or refreshes a browser subscription.

POST/v1/subscriptions

Authentication modes

ModeHowUse case
Browserwebsite_id in the body + an Origin header equal to the registered originThe SDK, called by the page
ServerAuthorization: Bearer <secret key>Relaying a subscription through your own backend, or a native app

Body

FieldTypeRequiredNotes
website_idstringbrowser modePublic identifier.
subscription.endpointstringyesHTTPS push endpoint. Host must be in ALLOWED_PUSH_HOSTS.
subscription.keys.p256dhstringyesbase64url, 65-byte uncompressed P-256 point.
subscription.keys.authstringyesbase64url, 16 bytes.
subscription.expiration_timenumber | string | nullnoEpoch milliseconds or ISO-8601. Stored for reference only.

A flat body ({ "endpoint": ..., "keys": ... }) is also accepted; the SDK uses the nested form.

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

201 Created / 200 OK

{
  "success": true,
  "site": { "public_identifier": "pub_8d1f0c...", "origin": "https://example.com" },
  "subscription": {
    "id": "sub_5m2k...",
    "status": "active",
    "created_at": "2026-01-31T12:10:00.000Z",
    "updated_at": "2026-01-31T12:10:00.000Z",
    "last_success_at": null
  },
  "created": true,
  "reactivated": false
}

The endpoint URL and the keys are never returned. created: false means an existing subscription was refreshed (same endpoint); reactivated: true means the row had been invalid or unsubscribed.

Errors

CodeStatusCause
INVALID_REQUEST400Missing website_id, malformed JSON, missing Origin in browser mode
INVALID_SUBSCRIPTION400Bad endpoint, p256dh or auth
PUSH_ENDPOINT_NOT_ALLOWED400Endpoint host not allow-listed
SITE_NOT_FOUND404Unknown identifier, or Origin ≠ registered origin
SITE_EXPIRED403Registration lapsed
SITE_LIMIT_REACHED409MAX_SUBSCRIPTIONS_PER_SITE exceeded
RATE_LIMIT_EXCEEDED429Subscription rate limit

DELETE /v1/subscriptions

browser: website_id + Origin or secret key Marks a subscription as unsubscribed. Idempotent.

curl -X DELETE https://YOUR-API-HOST/v1/subscriptions \
  -H 'Content-Type: application/json' \
  -H 'Origin: https://example.com' \
  -d '{"website_id":"pub_8d1f0c...","endpoint":"https://fcm.googleapis.com/fcm/send/XXXX"}'
{
  "success": true,
  "removed": true,
  "subscription": { "id": "sub_5m2k...", "status": "unsubscribed", "...": "..." },
  "site": { "public_identifier": "pub_8d1f0c..." }
}

Provide either endpoint or id. Unknown endpoints return removed: false with 200 — the desired end state already holds.


GET /v1/subscriptions

secret key Paginated list, newest first. Never includes endpoints or key material.

QueryDefaultRange
limit501–200
offset0≥ 0
{
  "success": true,
  "subscriptions": [
    { "id": "sub_5m2k...", "status": "active", "created_at": "2026-01-31T12:10:00.000Z",
      "updated_at": "2026-01-31T12:10:00.000Z", "last_success_at": "2026-01-31T12:20:00.000Z" }
  ],
  "counts": { "total": 1432, "active": 1402, "invalid": 21, "unsubscribed": 9 },
  "pagination": { "limit": 50, "offset": 0, "total": 1432, "has_more": true }
}

GET /v1/subscriptions/count

secret key Counts by status.

{
  "success": true,
  "counts": { "total": 1432, "active": 1402, "invalid": 21, "unsubscribed": 9 },
  "site": { "public_identifier": "pub_8d1f0c...", "status": "active" }
}

POST /v1/notifications

secret key Publishes a notification to every active subscription of the authenticated site.

POST/v1/notifications

Body fields

FieldTypeRequiredLimits
titlestringyes1–160 chars, single line
bodystringno≤ 1200 chars, newlines allowed
urlstringnohttp(s), absolute or relative to the registered origin, ≤ 2048
iconstringnohttp(s) absolute URL
badgestringnohttp(s) absolute URL
imagestringnohttp(s) absolute URL
tagstringno≤ 64 chars, single line; replaces the previous notification with the same tag
dataobjectnoJSON, ≤ 4 levels deep, ≤ 50 keys per object, ≤ 100 array items
actions[].urlstringnoWhere that button navigates. Absolute https URL, or a path on the registered origin. Previous releases accepted it but silently dropped it; it is now delivered.
actionsarrayno≤ 3 entries of { action, title, icon? }; browsers show at most 2
ttlintegerno0–2419200 seconds (RFC 8030); default PUSH_TTL_SECONDS (86400)
urgencystringnovery-low | low | normal | high

The whole payload, after removing empty fields, must serialise to at most MAX_PUSH_PAYLOAD_BYTES (3500 by default — the Web Push ciphertext ceiling is 3993 bytes).

curl -X POST https://YOUR-API-HOST/v1/notifications \
  -H "Authorization: Bearer $CREATOR_NOTIFY_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
        "title": "Special Discount!",
        "body": "Get 10% OFF your purchase for the next 10 hours.",
        "url": "https://example.com/offers",
        "image": "https://example.com/images/offer.jpg",
        "tag": "special-discount",
        "data": { "campaign": "discount-2026" }
      }'

200 OK — finished inline

{
  "success": true,
  "request_id": "req_3kd9...",
  "notification_id": "ntf_7hq2...",
  "status": "completed",
  "accepted_subscriptions": 1432,
  "successful_submissions": 1398,
  "failed_submissions": 21,
  "invalid_subscriptions": 13,
  "pending_submissions": 0,
  "timestamp": "2026-01-31T12:30:00.000Z",
  "site_expires_at": "2026-02-07T12:30:00.000Z",
  "warnings": ["SOME_PUSH_SUBMISSIONS_FAILED", "SOME_SUBSCRIPTIONS_WERE_INVALIDATED"]
}

202 Accepted — continues asynchronously

Returned when accepted_subscriptions > SYNC_DELIVERY_LIMIT. The counts describe the batches already attempted; status is queued when the remainder is being finished in the background, or processing when it is left to the cron sweep (with the warning ASYNC_DELIVERY_WITHOUT_QUEUE). Poll GET /v1/notifications/:id for the totals.

Warning codes

WarningMeaning
NO_ACTIVE_SUBSCRIPTIONSNobody to send to; nothing was attempted.
SOME_PUSH_SUBMISSIONS_FAILEDAt least one push service rejected a message.
SOME_SUBSCRIPTIONS_WERE_INVALIDATEDAt least one endpoint returned 404/410 and was disabled.
DELIVERY_CONTINUES_ASYNCHRONOUSLYNot all subscriptions were attempted in this request.
ASYNC_DELIVERY_WITHOUT_QUEUENo queue binding; the cron sweep finishes the job.

Errors

CodeStatusCause
INVALID_REQUEST400Validation failure; details.field names the field
INVALID_API_KEY401Missing or unknown key
SITE_EXPIRED403No publish for the inactivity window
SITE_SUSPENDED403Key revoked
NOTIFICATION_PAYLOAD_TOO_LARGE413Body > 32 KB or payload > MAX_PUSH_PAYLOAD_BYTES
RATE_LIMIT_EXCEEDED429Publish rate limit

GET /v1/notifications

secret key Newest first.

QueryDefaultRange
limit201–100
offset0≥ 0
{
  "success": true,
  "notifications": [
    {
      "id": "ntf_7hq2...",
      "request_id": "req_3kd9...",
      "status": "completed",
      "title": "Special Discount!",
      "body": "Get 10% OFF your purchase for the next 10 hours.",
      "tag": "special-discount",
      "url": "https://example.com/offers",
      "counts": { "accepted": 1432, "pending": 0, "succeeded": 1398, "failed": 21, "invalid": 13 },
      "error_code": null,
      "created_at": "2026-01-31T12:30:00.000Z",
      "completed_at": "2026-01-31T12:31:12.000Z"
    }
  ],
  "pagination": { "limit": 20, "offset": 0, "total": 57, "has_more": true }
}

Notification statuses

StatusMeaning
queuedPersisted, no delivery attempted yet.
processingSome subscriptions attempted; the rest is in progress.
completedEvery attempt succeeded.
partialMixed results: at least one success and at least one failure or invalid endpoint.
failedNo subscription accepted the message.

GET /v1/notifications/:id

secret key Detail plus the first 50 delivery attempts.

{
  "success": true,
  "notification": {
    "id": "ntf_7hq2...",
    "status": "partial",
    "counts": { "accepted": 1432, "pending": 0, "succeeded": 1398, "failed": 21, "invalid": 13 },
    "delivery_attempts": [
      { "subscription_id": "sub_5m2k...", "attempted_at": "2026-01-31T12:30:01.000Z",
        "status": "success", "http_status": 201, "error_code": null },
      { "subscription_id": "sub_9x1p...", "attempted_at": "2026-01-31T12:30:01.000Z",
        "status": "invalid", "http_status": 410, "error_code": "HTTP_410" }
    ],
    "delivery_attempts_truncated": true
  }
}

Errors: NOTIFICATION_NOT_FOUND (404) — including ids belonging to another site.


POST /v1/internal/sweep

sweep token Runs the scheduled jobs on demand: expiry, stalled deliveries, retention.

curl -X POST https://YOUR-API-HOST/v1/internal/sweep \
  -H "x-sweep-token: $SWEEP_TOKEN"
{
  "success": true,
  "sweep": {
    "expiration": { "expired": 0, "checked_at": "2026-01-31T12:45:00.000Z" },
    "deliveries": [],
    "retention": { "notifications": 0, "attempts": 0, "unsubscribed": 0, "rate_limits": 12,
                   "purged_at": "2026-01-31T12:45:00.000Z" }
  }
}

Returns 404 unless SWEEP_TOKEN is set

The route does not exist for deployments that have not configured the secret.