BETA
Skip to content

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=0

Required scope: push:read

Query Parameters ​

ParameterTypeDefaultDescription
statusstringFilter by status: draft, scheduled, sending, sent, failed (lib/krafter/push/push_notification.ex:44).
limitinteger50Maximum number of notifications to return.
offsetinteger0Number 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_id

Required 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:

KeyDescription
total_countNumber of subscribers selected by the targeting rule (set when dispatch begins).
total_batchesHow many delivery batches the audience was split into (@batch_size = 50 per push_dispatch_worker.ex:22).
completed_batchesBatches that have finished — equal to total_batches once finalized.
sent_countAtomic counter incremented per successful provider response.
failed_countAtomic 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/notifications

Required 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_at is given (and send is not true). PushScheduleWorker runs every minute via Oban cron and promotes due notifications to sending by calling Push.send_notification/1.
  • sending — PushDispatchWorker is enqueued. It writes total_count / total_batches into notification.stats first, then enqueues one PushDeliveryWorker per 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_at is stamped and webhook jobs for notification.sent are enqueued (only when status is sent, 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 ​

FieldTypeRequiredDescription
titlestringYesNotification title.
bodystringYesNotification body text.
iconstringNoURL to the notification icon.
imagestringNoURL to a large image displayed in the notification.
urlstringNoURL to open when the notification is clicked.
actionsobject[]NoAction buttons. Each action has title and action fields.
dataobjectNoCustom key-value data payload sent with the notification.
targetingobjectNoWho to send to. Defaults to {"type": "all"}. See Targeting below.
platform_overridesobjectNoPlatform-specific overrides. See Platform Overrides below.
scheduled_atstringNoISO 8601 datetime to schedule delivery. Without it the notification is sent as soon as it is created.
sendbooleanNoOnly 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.

ShapeAudience
{"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.

FieldTypeDescription
androidobjectAndroid-specific overrides (e.g., channel_id, priority).
iosobjectiOS-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/track

Required 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
}

Built by Krafter Studio