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
| Item | Value |
|---|---|
| Success envelope | { "success": true, ... } |
| Error envelope | { "success": false, "error": { "code", "message", "details" }, "request_id" } |
| Auth header | Authorization: Bearer cn_live_... |
| Content type | Content-Type: application/json (required for JSON bodies) |
| CORS | Only on browser-facing routes, only for registered origins. Never on management routes. |
| Machine-readable index | GET /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.
| Scope | Applies to | Default | Variable |
|---|---|---|---|
register | POST /v1/sites/register, per client IP | 10 / hour | RATE_LIMIT_REGISTER_PER_HOUR |
subscribe | Subscription creation + config, per site and per IP | 60 / minute | RATE_LIMIT_SUBSCRIBE_PER_MINUTE |
unsubscribe | DELETE /v1/subscriptions | 60 / minute | RATE_LIMIT_UNSUBSCRIBE_PER_MINUTE |
publish | POST /v1/notifications, per site | 120 / minute | RATE_LIMIT_PUBLISH_PER_MINUTE |
read | Status, lists, counts, key rotation | 300 / minute | RATE_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.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 2–120 characters, used only for your own reference. |
origin | string | yes | https://example.com. Scheme + host + optional port; no path, query or credentials. Stored lower-cased and normalised. |
contact_email | string | yes | Operational 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.
| Attempt | Response |
|---|---|
| 1 | 201 with site and credentials |
| 2 – 4 | 409 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
| Code | Status | Cause |
|---|---|---|
INVALID_REQUEST | 400 | Missing/invalid name or origin |
DUPLICATE_SITE | 409 | The origin is already registered |
REGISTRATION_DISABLED | 403 | REGISTRATION_ENABLED=false |
INVALID_API_KEY | 401 | Missing/incorrect x-registration-token |
RATE_LIMIT_EXCEEDED | 429 | More 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.
Authentication modes
| Mode | How | Use case |
|---|---|---|
| Browser | website_id in the body + an Origin header equal to the registered origin | The SDK, called by the page |
| Server | Authorization: Bearer <secret key> | Relaying a subscription through your own backend, or a native app |
Body
| Field | Type | Required | Notes |
|---|---|---|---|
website_id | string | browser mode | Public identifier. |
subscription.endpoint | string | yes | HTTPS push endpoint. Host must be in ALLOWED_PUSH_HOSTS. |
subscription.keys.p256dh | string | yes | base64url, 65-byte uncompressed P-256 point. |
subscription.keys.auth | string | yes | base64url, 16 bytes. |
subscription.expiration_time | number | string | null | no | Epoch 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
| Code | Status | Cause |
|---|---|---|
INVALID_REQUEST | 400 | Missing website_id, malformed JSON, missing Origin in browser mode |
INVALID_SUBSCRIPTION | 400 | Bad endpoint, p256dh or auth |
PUSH_ENDPOINT_NOT_ALLOWED | 400 | Endpoint host not allow-listed |
SITE_NOT_FOUND | 404 | Unknown identifier, or Origin ≠ registered origin |
SITE_EXPIRED | 403 | Registration lapsed |
SITE_LIMIT_REACHED | 409 | MAX_SUBSCRIPTIONS_PER_SITE exceeded |
RATE_LIMIT_EXCEEDED | 429 | Subscription 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.
| Query | Default | Range |
|---|---|---|
limit | 50 | 1–200 |
offset | 0 | ≥ 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.
Body fields
| Field | Type | Required | Limits |
|---|---|---|---|
title | string | yes | 1–160 chars, single line |
body | string | no | ≤ 1200 chars, newlines allowed |
url | string | no | http(s), absolute or relative to the registered origin, ≤ 2048 |
icon | string | no | http(s) absolute URL |
badge | string | no | http(s) absolute URL |
image | string | no | http(s) absolute URL |
tag | string | no | ≤ 64 chars, single line; replaces the previous notification with the same tag |
data | object | no | JSON, ≤ 4 levels deep, ≤ 50 keys per object, ≤ 100 array items |
actions[].url | string | no | Where 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. |
actions | array | no | ≤ 3 entries of { action, title, icon? }; browsers show at most 2 |
ttl | integer | no | 0–2419200 seconds (RFC 8030); default PUSH_TTL_SECONDS (86400) |
urgency | string | no | very-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
| Warning | Meaning |
|---|---|
NO_ACTIVE_SUBSCRIPTIONS | Nobody to send to; nothing was attempted. |
SOME_PUSH_SUBMISSIONS_FAILED | At least one push service rejected a message. |
SOME_SUBSCRIPTIONS_WERE_INVALIDATED | At least one endpoint returned 404/410 and was disabled. |
DELIVERY_CONTINUES_ASYNCHRONOUSLY | Not all subscriptions were attempted in this request. |
ASYNC_DELIVERY_WITHOUT_QUEUE | No queue binding; the cron sweep finishes the job. |
Errors
| Code | Status | Cause |
|---|---|---|
INVALID_REQUEST | 400 | Validation failure; details.field names the field |
INVALID_API_KEY | 401 | Missing or unknown key |
SITE_EXPIRED | 403 | No publish for the inactivity window |
SITE_SUSPENDED | 403 | Key revoked |
NOTIFICATION_PAYLOAD_TOO_LARGE | 413 | Body > 32 KB or payload > MAX_PUSH_PAYLOAD_BYTES |
RATE_LIMIT_EXCEEDED | 429 | Publish rate limit |
GET /v1/notifications
secret key Newest first.
| Query | Default | Range |
|---|---|---|
limit | 20 | 1–100 |
offset | 0 | ≥ 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
| Status | Meaning |
|---|---|
queued | Persisted, no delivery attempted yet. |
processing | Some subscriptions attempted; the rest is in progress. |
completed | Every attempt succeeded. |
partial | Mixed results: at least one success and at least one failure or invalid endpoint. |
failed | No 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.