Appearance
Notifications
Create, send, and manage push notifications. Notifications can be sent immediately, scheduled for later, or saved as drafts.
Base URL: https://app.krafter.dev/api/v1
List Notifications
Retrieve all notifications for an app, with optional filtering by status.
GET /push/apps/:app_id/notifications?status=...&limit=50&offset=0Required scope: push:read
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | Filter by status: draft, scheduled, sending, sent, failed (lib/krafter/push/push_notification.ex:44). | |
limit | integer | 50 | Maximum number of notifications to return. |
offset | integer | 0 | Number of notifications to skip for pagination. |
Example Request
bash
curl "https://app.krafter.dev/api/v1/push/apps/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d/notifications?status=sent&limit=20" \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": [
{
"id": "e5f6a7b8-9c0d-1e2f-3a4b-5c6d7e8f9a0b",
"app_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"title": "New Feature Available",
"body": "Check out our redesigned dashboard with real-time analytics.",
"icon": "https://example.com/icon.png",
"image": "https://example.com/banner.png",
"url": "https://example.com/dashboard",
"actions": [
{
"title": "Open Dashboard",
"action": "open_url"
}
],
"data": {
"feature": "dashboard-v2"
},
"targeting": {
"type": "tags",
"tags": ["beta"],
"match": "any"
},
"platform_overrides": null,
"status": "sent",
"scheduled_at": null,
"sent_at": "2025-06-14T12:00:00Z",
"stats": {
"total_count": 5200,
"total_batches": 52,
"completed_batches": 52,
"sent_count": 4980,
"failed_count": 220
},
"created_at": "2025-06-14T11:55:00Z",
"updated_at": "2025-06-14T12:00:00Z"
}
]
}Get Notification
Retrieve details of a specific notification, including delivery stats.
GET /push/apps/:app_id/notifications/:notification_idRequired scope: push:read
Notification stats schema
The stats field is the raw notification.stats jsonb map maintained by the dispatcher (push_dispatch_worker.ex:53-66, push_delivery_worker.ex:154-184). The keys are:
| Key | Description |
|---|---|
total_count | Number of subscribers selected by the targeting rule (set when dispatch begins). |
total_batches | How many delivery batches the audience was split into (@batch_size = 50 per push_dispatch_worker.ex:22). |
completed_batches | Batches that have finished — equal to total_batches once finalized. |
sent_count | Atomic counter incremented per successful provider response. |
failed_count | Atomic counter incremented per provider failure or expired token. |
Per-recipient delivered/clicked aggregates live in Analytics (the delivered daily counter is currently always 0 — see that page for why).
Example Request
bash
curl https://app.krafter.dev/api/v1/push/apps/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d/notifications/e5f6a7b8-9c0d-1e2f-3a4b-5c6d7e8f9a0b \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": {
"id": "e5f6a7b8-9c0d-1e2f-3a4b-5c6d7e8f9a0b",
"app_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"title": "New Feature Available",
"body": "Check out our redesigned dashboard with real-time analytics.",
"icon": "https://example.com/icon.png",
"image": "https://example.com/banner.png",
"url": "https://example.com/dashboard",
"actions": [
{
"title": "Open Dashboard",
"action": "open_url"
}
],
"data": {
"feature": "dashboard-v2"
},
"targeting": {
"type": "tags",
"tags": ["beta"],
"match": "any"
},
"platform_overrides": null,
"status": "sent",
"scheduled_at": null,
"sent_at": "2025-06-14T12:00:00Z",
"stats": {
"total_count": 5200,
"total_batches": 52,
"completed_batches": 52,
"sent_count": 4980,
"failed_count": 220
},
"created_at": "2025-06-14T11:55:00Z",
"updated_at": "2025-06-14T12:00:00Z"
}
}Create Notification
Create a notification and optionally send it immediately. Set send: true to send right away, provide scheduled_at to schedule for later, or omit both to save as a draft.
POST /push/apps/:app_id/notificationsRequired scope: push:write
Status lifecycle
A notification moves through draft → scheduled → sending → finalizing → sent (or failed if every batch fails). Concrete mechanics:
- draft — default when neither
sendnorscheduled_atis set. Never dispatched. - scheduled — set when
scheduled_atis in the future.PushScheduleWorkerruns every minute via Oban cron (config.exs:87) and promotes due notifications tosendingby callingPush.send_notification/1. - sending —
PushDispatchWorkeris enqueued. It streams matching subscribers in batches of@batch_size = 50(push_dispatch_worker.ex:22), enqueueing onePushDeliveryWorkerper recipient and writingtotal_count/total_batches/completed_batches: 0intonotification.stats. - finalizing — every recipient has a
PushDeliveryrow; the dispatcher atomically claims the row (push_delivery_worker.ex:193-207) so only one worker finalizes. - sent / failed — final state.
notification.sent_atis stamped and webhook jobs fornotification.sentare enqueued (only when status issent,push_dispatch_worker.ex:97-100).
Daily counters are written separately by PushStatsAggregator, which runs at 02:00 UTC (config.exs:88) and aggregates yesterday's deliveries into push_daily_stats.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Notification title. |
body | string | Yes | Notification body text. |
icon | string | No | URL to the notification icon. |
image | string | No | URL to a large image displayed in the notification. |
url | string | No | URL to open when the notification is clicked. |
actions | object[] | No | Action buttons. Each action has title and action fields. |
data | object | No | Custom key-value data payload sent with the notification. |
targeting | object | No | Who to send to. Defaults to {"type": "all"}. See Targeting below. |
platform_overrides | object | No | Platform-specific overrides. See Platform Overrides below. |
scheduled_at | string | No | ISO 8601 datetime to schedule delivery. |
send | boolean | No | Set to true to send immediately. Defaults to false (saved as draft). |
Targeting
The targeting object determines which subscribers receive the notification. The dispatcher selects the audience by switching on a required "type" discriminator (lib/krafter/workers/push_dispatch_worker.ex:131-172). Use one of the five shapes below — any other shape is silently treated as {"type": "all"} with a server-side warning, so a typo in "type" will broadcast to every active subscriber.
| Shape | Audience |
|---|---|
{"type": "all"} | every active subscriber for the app (default when targeting is omitted) |
{"type": "user_ids", "ids": ["user-1", "user-2"]} | subscribers whose user_id is in the list |
{"type": "tags", "tags": ["beta"], "match": "any"} | subscribers tagged with at least one of the listed tags |
{"type": "tags", "tags": ["beta", "ios"], "match": "all"} | subscribers tagged with all of the listed tags |
{"type": "platform", "platforms": ["web", "ios"]} | subscribers whose registered platform is in the list |
Unknown targeting falls through to "all"
A request body such as {"tags": ["beta"]} (without "type"), {"type": "segments", ...}, or any unrecognised shape does not return an error. The dispatcher logs a warning and sends to every active subscriber. Always include the "type" field exactly as shown above.
Segments are not supported
There is no segments targeting shape in the dispatcher today. Any segments field in earlier examples was incorrect — those payloads fell through to "all subscribers".
Platform Overrides
The platform_overrides object allows customizing notification behavior per platform.
| Field | Type | Description |
|---|---|---|
android | object | Android-specific overrides (e.g., channel_id, priority). |
ios | object | iOS-specific overrides (e.g., sound, badge, category). |
Example Request
bash
curl -X POST https://app.krafter.dev/api/v1/push/apps/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d/notifications \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"title": "New Feature Available",
"body": "Check out our redesigned dashboard with real-time analytics.",
"icon": "https://example.com/icon.png",
"image": "https://example.com/banner.png",
"url": "https://example.com/dashboard",
"actions": [
{
"title": "Open Dashboard",
"action": "open_url"
},
{
"title": "Dismiss",
"action": "dismiss"
}
],
"data": {
"feature": "dashboard-v2"
},
"targeting": {
"type": "tags",
"tags": ["beta"],
"match": "any"
},
"platform_overrides": {
"android": {
"channel_id": "features",
"priority": "high"
},
"ios": {
"sound": "default",
"badge": 1
}
},
"send": true
}'Example Response
json
// 201 Created
{
"data": {
"id": "e5f6a7b8-9c0d-1e2f-3a4b-5c6d7e8f9a0b",
"app_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"title": "New Feature Available",
"body": "Check out our redesigned dashboard with real-time analytics.",
"icon": "https://example.com/icon.png",
"image": "https://example.com/banner.png",
"url": "https://example.com/dashboard",
"actions": [
{
"title": "Open Dashboard",
"action": "open_url"
},
{
"title": "Dismiss",
"action": "dismiss"
}
],
"data": {
"feature": "dashboard-v2"
},
"targeting": {
"type": "tags",
"tags": ["beta"],
"match": "any"
},
"platform_overrides": {
"android": {
"channel_id": "features",
"priority": "high"
},
"ios": {
"sound": "default",
"badge": 1
}
},
"status": "sent",
"scheduled_at": null,
"sent_at": "2025-06-14T12:00:00Z",
"stats": {
"total_count": 5200,
"total_batches": 52,
"completed_batches": 0,
"sent_count": 0,
"failed_count": 0
},
"created_at": "2025-06-14T12:00:00Z",
"updated_at": "2025-06-14T12:00:00Z"
}
}Track Click
Track when a subscriber clicks a notification.
POST /push/notifications/:notification_id/trackRequired scope: push:write (lib/krafter_web/controllers/api/v1/push_controller.ex:42, 397-398).
Auth-gated, despite the name
This endpoint sits inside the api_authenticated pipeline and requires a push:write API key like every other write action. It is not a public endpoint and cannot be called directly from service workers or client-side code without leaking a credential that can also create and send notifications.
In practice this means real browser-side click tracking is not possible today — the only callers that can hit this endpoint are authenticated server-to-server clients. If you need click counts from end-user browsers, route the click through your own backend and forward to this endpoint server-side.
Per-app authorization is enforced
The action list in the ResourcePermission plug (push_controller.ex:51-67) keys off app_id in the URL, which track_click does not have. The controller therefore resolves the notification's app inline and applies the same push_app allow-list check (push_controller.ex:374-432): a key restricted to push app A cannot mark deliveries on push app B even when both live in the same team. Unrestricted keys (no per-app permissions) keep working as before.
Example Request
bash
curl -X POST https://app.krafter.dev/api/v1/push/notifications/e5f6a7b8-9c0d-1e2f-3a4b-5c6d7e8f9a0b/trackExample Response
json
{
"ok": true
}