Appearance
Notifications
Create, send, and manage push notifications. A notification is sent as soon as it is created, or scheduled for later with scheduled_at; the API has no draft state.
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. Without scheduled_at it is sent immediately, whether or not send is present. With scheduled_at it is scheduled for that time, unless send: true is also passed, which sends it now instead.
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 — the row as first inserted. The API moves it on in the same request, so a draft is never what you get back.
- scheduled — set when
scheduled_atis given (andsendis nottrue).PushScheduleWorkerruns every minute via Oban cron and promotes due notifications tosendingby callingPush.send_notification/1. - sending —
PushDispatchWorkeris enqueued. It writestotal_count/total_batchesintonotification.statsfirst, then enqueues onePushDeliveryWorkerper batch of 50 subscribers. A batch that is retried skips subscribers that already have a delivery row. - finalizing — the last batch has completed; one delivery worker atomically claims the row so only one 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. Without it the notification is sent as soon as it is created. |
send | boolean | No | Only meaningful together with scheduled_at: true sends now instead of scheduling. Without scheduled_at the notification is sent immediately either way. |
Targeting
The targeting object determines which subscribers receive the notification. It is validated when the notification is created: use one of the five shapes below, or the request is a 422 with the reason in errors.targeting and nothing is sent.
| 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 (match defaults to "any") |
{"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 |
Lists must be non-empty and platforms may only contain web, ios and android. Unknown types — {"type": "segments"}, a singular "user_id", or a body without "type" — are rejected rather than broadcast.
Segments are not supported
There is no segments targeting shape in the dispatcher today. Any segments field in earlier examples was incorrect — such a payload is now a 422.
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/track \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{"subscriber_id": "c3d4e5f6-7a8b-9c0d-1e2f-3a4b5c6d7e8f"}'subscriber_id is optional but you want it: without it every sent delivery of the notification is marked clicked, and the response says so with "scoped": false. A subscriber_id that matches no delivery — or is not a UUID — is a 404.
Example Response
json
{
"ok": true,
"deliveries_marked": 1,
"scoped": true
}