API Reference
Base URL: https://api.gesher.pro
Unless noted, JSON bodies use Content-Type: application/json. App routes expect:
Authorization: Bearer <APP_API_KEY>Admin routes expect:
Authorization: Bearer <ADMIN_API_KEY>Field names for app-facing subscribe/unsubscribe bodies are camelCase (userId, not user_id).
Public (no auth)
| Method | Path | Description |
|---|---|---|
GET | /health | Liveness check |
GET | /vapid-key | Public VAPID public key for PushManager.subscribe |
GET /health
Response 200
{ "ok": true, "ts": "2026-04-05T12:00:00.000Z" }(ts is an ISO 8601 string.)
GET /vapid-key
Response 200
{ "publicKey": "BFM3gqlY5hiALOWCxNL-..." }Error responses
| Status | Body |
|---|---|
500 | { "error": "VAPID_PUBLIC_KEY is missing or empty in worker bindings" } if the worker is misconfigured |
App API (Bearer = app API key)
| Method | Path | Description |
|---|---|---|
POST | /subscribe | Register or refresh a push subscription |
DELETE | /unsubscribe | Remove one subscription by endpoint |
POST | /notify | Broadcast to all subscribers (202, queued) |
POST | /notify/:userId | Send to one user’s devices (200, synchronous) |
GET | /stats | Aggregate metrics |
GET | /stats/subscriptions | Paginated subscribers |
GET | /stats/deliveries | Paginated delivery aggregates |
Common headers
Authorization: Bearer <API_KEY>
Content-Type: application/jsonPOST /subscribe
Registers or updates a subscription for (app_id, userId, endpoint).
Body (JSON)
{
"userId": "string (required)",
"subscription": {
"endpoint": "string (required — push endpoint URL)",
"keys": {
"p256dh": "string (required)",
"auth": "string (required)"
}
},
"tags": ["string (optional — stored as JSON array)"]
}tags defaults to [] if omitted.
Success 200
{ "ok": true }Error responses
| Status | When |
|---|---|
400 | Missing userId, subscription.endpoint, or keys |
401 | Missing/invalid Bearer token |
403 | App at subscriber cap (max_subscribers) for a new endpoint |
403 body (limit)
{
"error": "Subscription limit reached. Upgrade your plan.",
"limit": 1000,
"current": 1000
}DELETE /unsubscribe
Body (JSON)
{
"userId": "string (required)",
"endpoint": "string (required — push endpoint to remove)"
}Success 200
{ "ok": true }Error responses
| Status | When |
|---|---|
400 | Missing userId or endpoint |
401 | Missing/invalid Bearer token |
POST /notify (broadcast)
Sends a notification to all subscribers of the authenticated app. Work is continued in the background; the response is immediate.
Body (JSON)
{
"title": "string (required)",
"body": "string (optional)",
"url": "string (optional — opened on notification click)",
"icon": "string (optional)",
"badge": "string (optional)",
"tag": "string (optional — replaces same tag)"
}Success 202
{
"ok": true,
"notificationId": "550e8400-e29b-41d4-a716-446655440000",
"status": "queued"
}Error responses
| Status | When |
|---|---|
400 | Missing title |
401 | Missing/invalid Bearer token |
429 | Notify rate limit exceeded for the app’s plan |
429 body
{
"error": "Rate limit exceeded",
"limit": 100,
"window": "1 minute"
}limit depends on plan: free 100, starter 200, pro 10000 per rolling minute; enterprise is not limited in code.
POST /notify/:userId (targeted)
Same JSON body as broadcast.
Success 200
When the user has subscriptions:
{
"ok": true,
"sent": 2,
"errors": 0,
"notificationId": "550e8400-e29b-41d4-a716-446655440000"
}When none:
{
"ok": true,
"sent": 0,
"errors": 0,
"note": "No subscriptions found"
}(notificationId is omitted in the “no subscriptions” case.)
Error responses
| Status | When |
|---|---|
400 | Missing title |
401 | Missing/invalid Bearer token |
429 | Same as broadcast |
GET /stats
Success 200
{
"subscribers": 150,
"sent_24h": 42,
"failed_24h": 3
}failed_24hcounts delivery log rows in the last 24h with statusfailedorgone.
Error responses
| Status | When |
|---|---|
401 | Missing/invalid Bearer token |
GET /stats/subscriptions
Cursor-based pagination (50 rows per page). Cursor is the user_id of the last row from the previous page (string compare / ordering as implemented in the worker).
Query
| Param | Description |
|---|---|
cursor | Optional; omit for first page |
Success 200
{
"data": [
{
"user_id": "user-123",
"device_count": 2,
"last_active": "2026-04-05 10:00:00"
}
],
"next_cursor": "user-xyz"
}next_cursor is null when there is no next page.
GET /stats/deliveries
Paginated aggregates per notification_id (50 per page). Cursor is sent_at of the last row from the previous page.
Query
| Param | Description |
|---|---|
cursor | Optional; omit for first page |
Success 200
{
"data": [
{
"notification_id": "550e8400-e29b-41d4-a716-446655440000",
"sent": 10,
"failed": 1,
"gone": 2,
"sent_at": "2026-04-05 12:00:00"
}
],
"next_cursor": "2026-04-05 11:00:00"
}Admin API (Bearer = admin API key)
| Method | Path | Description |
|---|---|---|
POST | /admin/apps | Create app |
GET | /admin/apps | List apps (keys redacted) |
GET | /admin/apps/:id | Get one app (includes api_key) |
PATCH | /admin/apps/:id | Partial update |
DELETE | /admin/apps/:id | Delete app |
POST /admin/apps
Body (JSON) — create
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | Yes | Lowercase letters, numbers, hyphens only |
name | string | Yes | Display name |
api_key | string | Yes | Min 32 characters; must be unique |
owner_email | string | No | |
domain | string | No | |
plan | string | No | 'free' | 'starter' | 'pro' | 'enterprise'; default free |
max_subscribers | number | No | Positive integer; default 1000 |
webhook_url | string | No |
Success 201
{ "ok": true, "id": "my-app", "name": "My App" }Error responses
| Status | When |
|---|---|
400 | Validation (missing fields, bad id, short api_key, invalid plan / max_subscribers) |
401 | Invalid admin token |
409 | Duplicate id or api_key |
GET /admin/apps
Success 200
{
"apps": [
{
"id": "my-app",
"name": "My App",
"owner_email": null,
"domain": null,
"plan": "free",
"max_subscribers": 1000,
"is_active": 1,
"created_at": "..."
}
]
}(API keys are not included in list.)
GET /admin/apps/:id
Success 200 — full row including api_key.
Error responses
| Status | When |
|---|---|
401 | Invalid admin token |
404 | Unknown id |
PATCH /admin/apps/:id
Partial update. Only include fields to change.
Allowed fields
| Field | Type | Notes |
|---|---|---|
name | string | Required if present (non-null) |
owner_email | string | null | |
domain | string | null | |
webhook_url | string | null | |
plan | string | One of the plan enum values |
max_subscribers | number | Positive integer |
is_active | boolean or 0 / 1 |
Success 200
{ "ok": true, "id": "my-app" }Error responses
| Status | When |
|---|---|
400 | Invalid field values or empty patch |
401 | Invalid admin token |
404 | Unknown id |
DELETE /admin/apps/:id
Success 200
{ "ok": true }Error responses
| Status | When |
|---|---|
401 | Invalid admin token |
404 | Unknown id |
Error model
Most errors return JSON with an error string. Typical status codes:
| Code | Usage |
|---|---|
400 | Validation / malformed JSON |
401 | Missing or wrong Bearer token |
403 | Subscriber limit reached on subscribe |
404 | Admin app not found |
409 | Duplicate app on create |
429 | Notify rate limit |
500 | Server / configuration error (e.g. VAPID binding missing) |
There is no dedicated 502 JSON contract from Gesher today; push delivery failures are recorded in delivery_log (see stats) rather than returned on the HTTP response for broadcast.