21 KiB
Bulk & Scheduled Notifications — Integration Guide
This guide documents how admin clients integrate with the Yimaru backend to send immediate or scheduled bulk notifications across four channels:
| Channel | Value | Endpoint |
|---|---|---|
| SMS | sms |
POST /api/v1/notifications/bulk-sms |
email |
POST /api/v1/notifications/bulk-email |
|
| Push (FCM) | push |
POST /api/v1/notifications/bulk-push |
| In-app | in_app |
POST /api/v1/notifications/bulk-in-app |
Scheduled jobs are created by the same bulk endpoints when scheduled_at is provided. Management APIs list, inspect, and cancel pending jobs.
Table of contents
- Architecture overview
- Authentication & permissions
- Immediate vs scheduled
- Shared response envelopes
- Scheduled notification object
- Bulk SMS
- Bulk email
- Bulk push
- Bulk in-app
- Scheduled job management
- In-app real-time delivery (WebSocket)
- Recipient targeting rules
- Error handling
- Operational notes & limitations
- Admin UI integration checklist
Architecture overview
sequenceDiagram
participant Admin as Admin client
participant API as Yimaru API
participant DB as PostgreSQL
participant Worker as Scheduler worker
participant Channel as SMS / Email / FCM / WS
alt Immediate send (no scheduled_at)
Admin->>API: POST /notifications/bulk-*
API->>Channel: Deliver now
API-->>Admin: 200 + sent/failed counts
else Scheduled send (scheduled_at set)
Admin->>API: POST /notifications/bulk-* + scheduled_at
API->>DB: INSERT scheduled_notifications (pending)
API-->>Admin: 201 + ScheduledNotification
loop Every ~30s
Worker->>DB: Claim due pending rows
Worker->>Channel: Dispatch bulk delivery
Worker->>DB: Mark sent or failed
end
end
- The scheduler runs in-process on each API instance (30-second poll).
- Row claiming uses
FOR UPDATE SKIP LOCKEDso multiple API replicas can run safely. - There is no separate “create scheduled job” endpoint — scheduling is done via
scheduled_aton the bulk send endpoints.
Authentication & permissions
All bulk and scheduled-management endpoints require:
Authorization: Bearer <access_token>
Content-Type: application/json
(Push and email use multipart/form-data; see below.)
Required RBAC permissions
| Action | Permission key |
|---|---|
| Bulk SMS | notifications.bulk_sms |
| Bulk email | notifications.bulk_email |
| Bulk push | notifications.bulk_push |
| Bulk in-app | notifications.bulk_in_app |
| List scheduled jobs | notifications_scheduled.list |
| Get scheduled job | notifications_scheduled.get |
| Cancel scheduled job | notifications_scheduled.cancel |
Team members with the ADMIN role bundle receive these permissions after RBAC seeding. Ensure migration 000083 has been applied for in_app scheduled channel support.
Immediate vs scheduled
scheduled_at |
Behavior | HTTP status |
|---|---|---|
| Omitted or empty | Send immediately | 200 OK |
| Present, RFC3339, future | Create scheduled job | 201 Created |
| Present, past | Rejected | 400 Bad Request |
| Invalid format | Rejected | 400 Bad Request |
Format: RFC3339 UTC recommended, e.g. 2026-06-15T09:00:00Z
Shared response envelopes
Success (domain.Response)
{
"message": "Human-readable summary",
"data": { },
"success": true,
"status_code": 200,
"metadata": null
}
Error (domain.ErrorResponse)
{
"message": "Short error title",
"error": "Optional detail string"
}
Immediate send data shape (SMS, email, in-app)
{
"total_recipients": 150,
"sent": 148,
"failed": 2
}
Immediate push data shape
{
"target_users": 150,
"sent": 200,
"failed": 5,
"image": "https://api.example.com/files/notification_images/abc.jpg"
}
Push
sent/failedcount device tokens, not users. One user may have multiple devices.
Scheduled notification object
Returned when scheduling (201) and from management GET endpoints.
{
"id": 42,
"channel": "sms",
"title": "Optional subject / headline",
"message": "Body text",
"html": "Optional HTML (email only)",
"scheduled_at": "2026-06-15T09:00:00.000Z",
"status": "pending",
"target_user_ids": [1, 2, 3],
"target_role": "STUDENT",
"target_raw": { "phones": ["+251911000000"] },
"attempt_count": 0,
"last_error": "",
"processing_started_at": null,
"sent_at": null,
"cancelled_at": null,
"created_by": 7,
"created_at": "2026-06-10T12:00:00.000Z",
"updated_at": "2026-06-10T12:00:00.000Z"
}
channel values
| Value | Description |
|---|---|
sms |
AfroMessage SMS |
email |
Resend / messenger email |
push |
Firebase Cloud Messaging |
in_app |
DB notification + WebSocket broadcast |
status lifecycle
| Status | Meaning |
|---|---|
pending |
Waiting for scheduled_at |
processing |
Claimed by worker, dispatch in progress |
sent |
Dispatch completed (at least one recipient succeeded) |
failed |
All deliveries failed or unsupported channel |
cancelled |
Cancelled via API before send |
target_raw structure
Used when recipients cannot be expressed only via target_user_ids / target_role:
{
"phones": ["+251911000000", "+251922000000"],
"emails": ["user@example.com"],
"type": "system_alert",
"level": "info"
}
| Field | Used by |
|---|---|
phones |
Scheduled SMS with direct phone numbers |
emails |
Scheduled email with direct addresses |
type, level |
Scheduled in-app metadata (defaults: system_alert, info) |
Bulk SMS
Endpoint
POST /api/v1/notifications/bulk-sms
Content-Type: application/json
Permission: notifications.bulk_sms
Request body
| Field | Type | Required | Description |
|---|---|---|---|
message |
string | Yes | SMS body |
user_ids |
int64[] | No* | Platform user IDs (resolved to phone_number) |
role |
string | No* | All users with this platform role |
phone_numbers |
string[] | No* | Direct E.164 or local numbers |
scheduled_at |
string | No | RFC3339; omit for immediate send |
* At least one of user_ids, role, or phone_numbers is required.
Example — immediate
POST /api/v1/notifications/bulk-sms
Authorization: Bearer <token>
Content-Type: application/json
{
"message": "Your subscription expires tomorrow.",
"role": "STUDENT"
}
Example — scheduled
{
"message": "Reminder: class starts at 9 AM.",
"user_ids": [10, 11, 12],
"scheduled_at": "2026-06-15T08:00:00Z"
}
Example — scheduled with direct phones
{
"message": "Welcome to Yimaru!",
"phone_numbers": ["+251911000000"],
"scheduled_at": "2026-06-15T09:00:00Z"
}
Success responses
Immediate (200):
{
"message": "Bulk SMS sent",
"success": true,
"status_code": 200,
"data": {
"total_recipients": 3,
"sent": 3,
"failed": 0
}
}
Scheduled (201):
{
"message": "SMS scheduled",
"success": true,
"status_code": 201,
"data": { /* ScheduledNotification */ }
}
Bulk email
Endpoint
POST /api/v1/notifications/bulk-email
Content-Type: multipart/form-data
Permission: notifications.bulk_email
Form fields
| Field | Type | Required | Description |
|---|---|---|---|
subject |
string | Yes | Email subject (stored as title when scheduled) |
message |
string | No** | Plain-text body |
html |
string | No** | HTML body |
user_ids |
string | No* | JSON array string, e.g. [1,2,3] |
role |
string | No* | Platform role filter |
emails |
string | No* | JSON array string, e.g. ["a@b.com"] |
scheduled_at |
string | No | RFC3339 |
file |
file | No | Attachment (immediate send only) |
* At least one of user_ids, role, or emails is required.
** At least one of message or html is required.
Example — immediate (cURL)
curl -X POST "https://api.example.com/api/v1/notifications/bulk-email" \
-H "Authorization: Bearer <token>" \
-F "subject=New course available" \
-F "html=<p>Check out our new module!</p>" \
-F "role=STUDENT" \
-F "file=@banner.png"
Example — scheduled (cURL)
curl -X POST "https://api.example.com/api/v1/notifications/bulk-email" \
-H "Authorization: Bearer <token>" \
-F "subject=Weekly digest" \
-F "message=Here is your weekly summary." \
-F "emails=[\"admin@example.com\",\"user@example.com\"]" \
-F "scheduled_at=2026-06-15T09:00:00Z"
Note: Attachments are not stored for scheduled emails. Upload and send immediately if an attachment is required.
Success responses
Immediate (200):
{
"message": "Bulk email sent",
"success": true,
"status_code": 200,
"data": {
"total_recipients": 50,
"sent": 49,
"failed": 1
}
}
Scheduled (201):
{
"message": "Email scheduled",
"success": true,
"status_code": 201,
"data": {
"id": 43,
"channel": "email",
"title": "Weekly digest",
"message": "Here is your weekly summary.",
"html": "",
"scheduled_at": "2026-06-15T09:00:00.000Z",
"status": "pending",
"target_raw": { "emails": ["admin@example.com", "user@example.com"] },
"created_by": 7
}
}
Bulk push
Endpoint
POST /api/v1/notifications/bulk-push
Content-Type: multipart/form-data
Permission: notifications.bulk_push
Requires FCM credentials (FCM_SERVICE_ACCOUNT_KEY) on the server.
Form fields
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | Yes | Notification title |
message |
string | Yes | Notification body |
user_ids |
string | No* | JSON array string, e.g. [1,2,3] |
role |
string | No* | Platform role filter |
scheduled_at |
string | No | RFC3339 |
file |
file | No | Image (immediate send only) |
* At least one of user_ids or role is required.
Example — immediate
curl -X POST "https://api.example.com/api/v1/notifications/bulk-push" \
-H "Authorization: Bearer <token>" \
-F "title=Practice reminder" \
-F "message=Complete today's practice." \
-F "user_ids=[1,2,3]" \
-F "file=@thumb.jpg"
Example — scheduled
curl -X POST "https://api.example.com/api/v1/notifications/bulk-push" \
-H "Authorization: Bearer <token>" \
-F "title=Exam tomorrow" \
-F "message=Don't forget to review Unit 3." \
-F "role=STUDENT" \
-F "scheduled_at=2026-06-14T18:00:00Z"
Note: Push images are not preserved when scheduling. Use immediate send for image pushes.
Success responses
Immediate (200):
{
"message": "Bulk push notification sent",
"success": true,
"status_code": 200,
"data": {
"target_users": 3,
"sent": 4,
"failed": 0,
"image": "https://api.example.com/files/notification_images/abc.jpg"
}
}
Scheduled (201):
{
"message": "Push notification scheduled",
"success": true,
"status_code": 201,
"data": { /* ScheduledNotification, channel: "push" */ }
}
Bulk in-app
In-app notifications are persisted in the notifications table and pushed to connected clients via WebSocket (CREATED_NOTIFICATION event). Users without an active socket still see them on next GET /api/v1/notifications.
Endpoint
POST /api/v1/notifications/bulk-in-app
Content-Type: application/json
Permission: notifications.bulk_in_app
Request body
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | Yes | Headline (payload.headline) |
message |
string | Yes | Body (payload.message) |
user_ids |
int64[] | No* | Target user IDs |
role |
string | No* | All users with platform role |
scheduled_at |
string | No | RFC3339 |
type |
string | No | Notification type (default system_alert) |
level |
string | No | info, warning, error, success (default info) |
* At least one of user_ids or role is required.
Notification type values (common)
| Value | Use case |
|---|---|
system_alert |
Admin broadcasts (default) |
subscription_expiring |
Billing reminders |
course_completed |
Learning milestones |
payment_verified |
Payment events |
Example — immediate
{
"title": "Maintenance tonight",
"message": "The app will be unavailable 11 PM–1 AM UTC.",
"role": "STUDENT"
}
Example — scheduled
{
"title": "New practice unlocked",
"message": "Unit 4 practice is now available.",
"user_ids": [100, 101, 102],
"type": "system_alert",
"level": "info",
"scheduled_at": "2026-06-16T07:00:00Z"
}
Success responses
Immediate (200):
{
"message": "Bulk in-app notification sent",
"success": true,
"status_code": 200,
"data": {
"total_recipients": 3,
"sent": 3,
"failed": 0
}
}
Scheduled (201):
{
"message": "In-app notification scheduled",
"success": true,
"status_code": 201,
"data": {
"id": 44,
"channel": "in_app",
"title": "New practice unlocked",
"message": "Unit 4 practice is now available.",
"scheduled_at": "2026-06-16T07:00:00.000Z",
"status": "pending",
"target_user_ids": [100, 101, 102],
"target_raw": { "type": "system_alert", "level": "info" }
}
}
Scheduled job management
List scheduled notifications
GET /api/v1/notifications/scheduled
Permission: notifications_scheduled.list
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
status |
string | — | pending, processing, sent, failed, cancelled |
channel |
string | — | sms, email, push, in_app |
after |
RFC3339 | — | scheduled_at >= after |
before |
RFC3339 | — | scheduled_at <= before |
limit |
int | 20 | Page size |
page |
int | 1 | Page number (1-based) |
Example
GET /api/v1/notifications/scheduled?channel=in_app&status=pending&page=1&limit=20
Authorization: Bearer <token>
Response
{
"scheduled_notifications": [ /* ScheduledNotification[] */ ],
"total_count": 42,
"limit": 20,
"page": 1
}
Get one scheduled notification
GET /api/v1/notifications/scheduled/:id
Permission: notifications_scheduled.get
Response (200)
{
"message": "Scheduled notification retrieved",
"success": true,
"status_code": 200,
"data": { /* ScheduledNotification */ }
}
Cancel scheduled notification
POST /api/v1/notifications/scheduled/:id/cancel
Permission: notifications_scheduled.cancel
Only jobs with status pending or processing can be cancelled.
Response (200)
{
"message": "Scheduled notification cancelled",
"success": true,
"status_code": 200,
"data": {
"id": 42,
"status": "cancelled",
"cancelled_at": "2026-06-10T14:30:00.000Z"
}
}
In-app real-time delivery (WebSocket)
Mobile/web clients should connect to receive in-app notifications instantly.
Connect
GET /api/v1/ws/connect?token=<access_token>
Permission: notifications.ws_connect (validated via query token)
Auth uses the JWT access token in query string (not Authorization header).
Broadcast payload
When an in-app notification is created, connected clients receive:
{
"type": "CREATED_NOTIFICATION",
"recipient_id": 123,
"payload": {
"id": "notif-uuid",
"recipient_id": 123,
"type": "system_alert",
"level": "info",
"delivery_channel": "in_app",
"payload": {
"headline": "Title",
"message": "Body"
},
"is_read": false,
"timestamp": "2026-06-10T12:00:00.000Z"
}
}
Polling fallback
GET /api/v1/notifications?limit=20&offset=0
Permission: notifications.list_mine
GET /api/v1/notifications/unread
Permission: notifications.count_unread
Recipient targeting rules
Platform roles (role field)
Valid platform users.role values:
| Role | Value |
|---|---|
| Student | STUDENT |
| Open learner | OPEN_LEARNER |
| Instructor | INSTRUCTOR |
| Admin | ADMIN |
| Super admin | SUPER_ADMIN |
| Support | SUPPORT |
When role is set without user_ids, the API loads all users matching that role.
Combining targets
| Channel | user_ids |
role |
Direct (phones / emails) |
|---|---|---|---|
| SMS | ✓ | ✓ | phone_numbers |
| ✓ | ✓ | emails (form field) |
|
| Push | ✓ | ✓ | — |
| In-app | ✓ | ✓ | — |
For immediate SMS/email, all sources are unioned (deduplicated). Scheduled jobs store the targeting fields as-is and resolve at dispatch time.
Error handling
Common 400 errors
| Message | Cause |
|---|---|
Message is required |
Empty SMS/in-app body |
Title is required |
Empty push/in-app title |
No recipients specified |
Missing all targeting fields |
No target users found |
Role/user_ids resolved to zero users |
Invalid scheduled_at format |
Not RFC3339 |
scheduled_at must be in the future |
Past timestamp |
Invalid user_ids format |
Push/email: not a JSON array string |
Common 401 / 403
| Situation | Result |
|---|---|
| Missing/invalid Bearer token | 401 |
| Missing RBAC permission | 403 |
Common 500
| Situation | Result |
|---|---|
| FCM not configured (push) | Push send fails |
| AfroMessage / Resend misconfigured | SMS/email failures |
| DB error creating scheduled row | Schedule request fails |
Partial delivery
For immediate sends, the API returns sent and failed counts. The HTTP status is still 200 if the request was processed.
For scheduled jobs, if at least one recipient succeeds, status becomes sent. If all fail, status becomes failed and last_error is set.
Operational notes & limitations
| Topic | Detail |
|---|---|
| Scheduler interval | ~30 seconds (not exact-to-the-second) |
| Scheduled push images | Not supported — image upload only on immediate push |
| Scheduled email attachments | Not supported — attachments only on immediate send |
| Scheduled job retry | No automatic retry; failed jobs stay failed |
| Edit / reschedule | Not supported — cancel and create a new job |
| Multi-instance | Safe via DB row locking |
| SMS provider | AfroMessage |
| Email provider | Resend (messenger service) |
| Push provider | Firebase Cloud Messaging |
| History records | Immediate SMS/email/push call RecordNotification for known user_ids; scheduled SMS/email/push do not write per-user history rows. In-app always creates notification rows (immediate and scheduled). |
| Migration | 000083 required for in_app scheduled channel |
Admin UI integration checklist
Compose screen (per channel)
- Title/subject + message fields (channel-specific)
- Recipient picker: user multi-select, role dropdown, and/or direct phones/emails
- Toggle: Send now vs Schedule
- Date/time picker → serialize as RFC3339 UTC (
scheduled_at) - Optional: image upload (push immediate only), email attachment (immediate only)
- Optional: in-app
typeandlevelselectors
After submit
- Immediate: show
sent/failed/total_recipientsfromdata - Scheduled: show returned
id,scheduled_at,status— link to job detail view
Scheduled jobs list
- Call
GET /notifications/scheduledwith filters (status,channel, date range) - Paginate with
page+limit - Show status badges: pending → processing → sent / failed / cancelled
- Cancel button →
POST /notifications/scheduled/:id/cancelforpending/processing
Learner app (in-app only)
- WebSocket connect on login with
?token= - Handle
CREATED_NOTIFICATIONevents - Fallback poll
GET /notificationsandGET /notifications/unread
Quick reference — all endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/notifications/bulk-sms |
Bulk / schedule SMS |
POST |
/api/v1/notifications/bulk-email |
Bulk / schedule email |
POST |
/api/v1/notifications/bulk-push |
Bulk / schedule push |
POST |
/api/v1/notifications/bulk-in-app |
Bulk / schedule in-app |
GET |
/api/v1/notifications/scheduled |
List scheduled jobs |
GET |
/api/v1/notifications/scheduled/:id |
Get scheduled job |
POST |
/api/v1/notifications/scheduled/:id/cancel |
Cancel scheduled job |
GET |
/api/v1/ws/connect?token= |
In-app WebSocket |
GET |
/api/v1/notifications |
User notification inbox |