This document explains the implemented notification lifecycle in Notify-Chain. It covers both:
- Scheduled notifications managed in SQLite and executed by background schedulers.
- Real-time event notifications sent directly by the event subscriber.
The focus is accuracy against current code paths in listener/src.
Notify-Chain currently has two delivery paths:
- Scheduled path (durable, DB-backed)
- Creation via
NotificationAPI.scheduleNotification()orPOST /api/schedule. - Stored in
scheduled_notifications. - Delivered by
NotificationScheduler. - Retries managed by
RetryScheduler. - Execution history written to
notification_execution_log. - Terminal rows archived by
ArchiveServiceintonotification_archive.
- Real-time subscriber path (event-driven)
- Events polled from Stellar RPC by
EventSubscriber. - Valid events are added to
eventRegistry. - Discord delivery attempted immediately.
- Optional in-memory retry via
NotificationRetryQueuefor immediate failures.
stateDiagram-v2
[*] --> PENDING: create schedule record
PENDING --> PROCESSING: fetch+lock due row
PROCESSING --> COMPLETED: provider delivery acknowledged
PROCESSING --> PENDING: failure + retries remaining
PROCESSING --> FAILED: failure + retries exhausted
PENDING --> CANCELLED: cancel before processing
COMPLETED --> ARCHIVED: archive cycle moves row
FAILED --> ARCHIVED: archive cycle moves row
CANCELLED --> ARCHIVED: archive cycle moves row
ARCHIVED --> PURGED: purge after retention window
POST /api/schedule in listener/src/api/events-server.ts creates scheduled notifications.
Required request fields:
executeAtpayloadtargetRecipient
Optional fields passed through to storage:
notificationType(defaults todiscord)maxRetriespriorityeventIdcontractAddressmetadata
Example:
{
"payload": {
"event": { "id": "evt_123" },
"contractConfig": { "address": "CA..." }
},
"notificationType": "discord",
"targetRecipient": "https://discord.com/api/webhooks/...",
"executeAt": "2026-07-01T09:00:00.000Z",
"maxRetries": 3,
"priority": 5,
"eventId": "evt_123",
"contractAddress": "CA...",
"metadata": { "source": "manual" }
}NotificationAPI.scheduleNotification() validates:
executeAtis a validDateexecuteAtis in the futurepayloadis an objecttargetRecipientexists
If validation fails, the call throws an error and no DB row is created.
NotificationAPI supports idempotency if initialized with IdempotencyKeyService and called with an idempotency key.
Behavior:
- Same key + same request body: returns cached response notification id.
- Same key + different request body: rejects with
Idempotency key reused with different request body.
There are two distinct validation layers:
- Schedule input validation
- Happens in
NotificationAPI.scheduleNotification()before insert.
- Batch payload validation
POST /api/notifications/validate-batchvalidates batches viaBatchValidationServiceandBatchValidator.- Scheduler also validates fetched notifications as a batch before processing and marks each row retry/failed if the batch is invalid.
Scheduling behavior:
- New records start as
PENDINGinscheduled_notifications. - Due records are selected when
execute_at <= now. - Locking is optimistic/distributed via
status=PROCESSING,processor_id, andlock_expires_at.
sequenceDiagram
participant Client
participant API as Events API (/api/schedule)
participant NAPI as NotificationAPI
participant Repo as ScheduledNotificationRepository
participant Sched as NotificationScheduler
participant Discord as DiscordNotificationService
participant DB as SQLite
Client->>API: POST /api/schedule
API->>NAPI: scheduleNotification(input)
NAPI->>Repo: create(...)
Repo->>DB: INSERT scheduled_notifications (PENDING)
DB-->>Repo: id
Repo-->>API: id
API-->>Client: 201 { id }
loop poll every SCHEDULER_POLL_INTERVAL_MS
Sched->>Repo: recoverStaleLocks()
Sched->>Repo: fetchAndLockPendingNotifications()
Repo->>DB: UPDATE ... SET PROCESSING + lock
Repo-->>Sched: locked rows
Sched->>Discord: sendEventNotification(...)
alt success
Sched->>Repo: markAsCompleted(id)
Sched->>Repo: logExecution(status=SUCCESS)
else failed
Sched->>Repo: markAsFailedOrRetry(...)
Sched->>Repo: logExecution(status=RETRY or FAILED)
end
end
- Current production delivery implementation is only
discord. webhook,email, andsmsnotification types are declared but not yet implemented inNotificationScheduler.executeNotification().DiscordNotificationServicedoes notification deduplication and marks delivery success/failure from webhook HTTP response.
Failure path in scheduler:
NotificationSchedulercallsmarkAsFailedOrRetry().retry_countincrements.- If retries remain: status returns to
PENDING. - If retries exhausted: status becomes
FAILEDandprocessing_completed_atis set.
RetryScheduler then handles delayed retries for rows where:
status = PENDINGretry_count > 0next_retry_at IS NULL OR next_retry_at <= now
Backoff:
calculateBackoffDelay(attempt, baseDelayMs, multiplier, maxDelayMs, jitter)- Optional jitter (default enabled) to reduce synchronized retry spikes.
recoverStaleLocks() returns expired PROCESSING rows to PENDING (or FAILED if max retries reached) and logs execution outcomes.
In the real-time path (EventSubscriber), immediate Discord failures can be queued into NotificationRetryQueue (in-memory exponential backoff). This path is separate from DB-backed scheduled retries.
Acknowledgment is currently delivery-provider based, not end-user/manual.
Implemented acknowledgment behavior:
- Provider HTTP acknowledgment
DiscordNotificationService.sendWebhook()receives an HTTP response.response.ok === trueis treated as positive delivery acknowledgment.- The scheduler marks the notification
COMPLETEDand logsSUCCESS.
- Provider failure / no acknowledgment
- Non-2xx response or transport error returns false/throws.
- Scheduler records retry/failure and logs
RETRYorFAILEDinnotification_execution_log.
Not currently implemented:
- There is no dedicated endpoint like
POST /api/notifications/:id/ackfor downstream consumer acknowledgment.
Once a notification reaches terminal state (COMPLETED, FAILED, or CANCELLED):
- It remains in
scheduled_notificationsfor an active retention window. ArchiveServiceperiodically moves old terminal rows tonotification_archive.- Optional purge deletes old archived rows after a second retention window.
Archival defaults from ArchiveConfig:
- Archive cycle interval: 6 hours (
ARCHIVE_INTERVAL_MS) - Archive-after threshold: 7 days (
ARCHIVE_AFTER_MS) - Purge-after threshold: 90 days (
ARCHIVE_DELETE_AFTER_MS) - Batch size: 500 (
ARCHIVE_BATCH_SIZE)
Read APIs:
GET /api/archiveGET /api/archive/:idPOST /api/archive/run(on-demand cycle)
- Scheduled and subscriber flows are intentionally separate; avoid assuming one substitutes for the other.
- Scheduled retries are durable (SQLite). Subscriber retries are in-memory.
notification_execution_logis the authoritative execution audit trail.- Worker shutdown uses
WorkerManagerso schedulers/archive cycles can finish in-flight jobs gracefully.
Check:
SCHEDULER_ENABLED=trueexecute_atis in the past or near-future and valid- Scheduler logs show polling and lock acquisition
Useful API checks:
GET /api/schedule/<id>GET /api/schedule/stats
Check:
- Discord webhook validity/reachability
retry_count,next_retry_at, andlast_errorinscheduled_notifications- Execution log entries in
notification_execution_log
If terminal and older than retention window, it may already be archived.
Check:
GET /api/archive?status=COMPLETEDGET /api/archive?status=FAILED
Current implementation does not expose a user-ack endpoint. Delivery acknowledgment is inferred from provider response success/failure.