Canonical doc: NOTIFICATION_LIFECYCLE.md (repo root). Prefer that document for the complete on-chain/off-chain lifecycle, component roles, Mermaid diagrams, acknowledgment, retry, and archival. This file retains scheduled-notification details (state machine, schema, APIs) as a deeper reference for the durable path only.
See also the root lifecycle guide: NOTIFICATION_LIFECYCLE.md.
The NotifyChain notification system is a robust, multi-component system designed to capture events from Soroban smart contracts, deliver them to configured destinations, and track their entire lifecycle for auditing and debugging purposes.
This document describes:
- The complete state machine for notifications
- The event sequence from creation to delivery/acknowledgment
- The system components involved in each step
- Failure modes and recovery mechanisms
| Status | Meaning | Trigger |
|---|---|---|
PENDING |
Notification is scheduled but not yet processed | Notification creation via NotificationAPI.scheduleNotification() |
PROCESSING |
Notification is currently being delivered to its destination | NotificationScheduler.fetchAndLockPendingNotifications() |
COMPLETED |
Notification was successfully delivered to the destination | Successful execution in NotificationScheduler.processNotification() |
FAILED |
Delivery failed after maximum retries | markAsFailedOrRetry() when retryCount >= maxRetries |
CANCELLED |
Notification was cancelled before delivery | NotificationAPI.cancelNotification() |
stateDiagram-v2
[*] --> PENDING: scheduleNotification()
PENDING --> PROCESSING: fetchAndLockPendingNotifications()
PROCESSING --> COMPLETED: markAsCompleted() (success)
PROCESSING --> PENDING: markAsFailedOrRetry() (retryable)
PROCESSING --> FAILED: markAsFailedOrRetry() (max retries)
PENDING --> CANCELLED: cancelNotification()
Located at: listener/src/types/scheduled-notification.ts
interface ScheduledNotification {
id: number; // Auto-incremented unique ID
payload: string; // JSON string of notification payload
notificationType: NotificationType; // 'discord', 'email', 'webhook', 'sms'
targetRecipient: string; // Webhook URL, email address, etc.
executeAt: Date; // When to attempt delivery
createdAt: Date;
updatedAt: Date;
status: NotificationStatus; // See status enum
retryCount: number;
maxRetries: number;
processingStartedAt?: Date;
processingCompletedAt?: Date;
processorId?: string;
lockExpiresAt?: Date;
lastError?: string;
errorDetails?: string;
eventId?: string; // Original blockchain event ID (if applicable)
contractAddress?: string;
priority: number; // 1-10, lower = higher priority
metadata?: string; // Custom JSON metadata
nextRetryAt?: Date;
}Located at: listener/src/types/scheduled-notification.ts
Every delivery attempt is logged to notification_execution_log table for auditing.
interface NotificationExecutionLog {
id?: number;
scheduledNotificationId: number;
executionAttempt: number;
executionTime: Date;
status: 'SUCCESS' | 'FAILED' | 'RETRY';
errorMessage?: string | null;
responseData?: string | null;
durationMs?: number | null;
}graph TD
A[EventSubscriber] --> B[NotificationDeduplicator]
B --> C[DiscordNotificationService]
D[NotificationAPI] --> E[ScheduledNotificationRepository]
E --> F[Database]
G[NotificationScheduler] --> F
G --> H[DiscordNotificationService]
I[RetryScheduler] --> F
I --> H
- NotificationAPI: Public entry point for scheduling notifications
- ScheduledNotificationRepository: Database operations for notifications
- NotificationScheduler: Background worker that processes pending notifications
- DiscordNotificationService: Handles Discord webhook delivery
- NotificationDeduplicator: Prevents duplicate delivery of the same event
- EventSubscriber: Polls Soroban for new events and triggers notifications
- RetryScheduler: Processes failed notifications for re-attempt
There are two main ways a notification can be created:
- User calls
NotificationAPI.scheduleNotification() - Input is validated:
executeAtmust be a valid future datepayloadmust be an objecttargetRecipientmust be provided
- If idempotency key is provided,
IdempotencyKeyServicechecks for duplicates ScheduledNotificationRepository.create()inserts the notification into the database with status:PENDINGcreatedAttimestamp is set automatically
Example creation code:
// Using TypeScript API
const notificationId = await api.scheduleNotification({
payload: { message: "Hello!" },
notificationType: NotificationType.DISCORD,
targetRecipient: "https://discord.com/api/webhooks/...",
executeAt: new Date(Date.now() + 3600000), // 1 hour from now
maxRetries: 3,
priority: 5
});Blockchain events follow the real-time path documented in the canonical lifecycle:
EventSubscriberpolls Soroban RPC for new events- Events are deduplicated via
EventDeduplicationService(persistent) andNotificationDeduplicator(in-memory, at Discord send time) - Events are added to
eventRegistry(forGET /api/events/ dashboard) - If Discord preferences allow it,
DiscordNotificationServicedelivers immediately - Failures may enqueue
NotificationRetryQueue(in-memory) — they are not automatically inserted intoscheduled_notifications
Use POST /api/schedule / NotificationAPI when you need durable deferred delivery.
The NotificationScheduler runs in a loop (default: every 10 seconds):
-
Stale Lock Recovery: First, it recovers any locks from crashed workers
- Checks
PROCESSINGnotifications wherelockExpiresAt < now - Increments retry count
- If max retries reached, marks as
FAILED, else marks asPENDING - Logs the recovery attempt
- Checks
-
Fetch & Lock Pending Notifications:
- Uses atomic database query to lock notifications with status
PENDINGandexecuteAt <= now - Updates status to
PROCESSING - Sets
processorId,lockExpiresAt(default: 60s), andprocessingStartedAt - Orders by priority (ascending) and executeAt (ascending)
- Uses atomic database query to lock notifications with status
-
Process Each Notification:
- Validates notification batch using
BatchValidationService - Checks if shutdown is in progress
- For each notification in the batch:
a. Verifies it's within timing buffer (default: ±60s)
b. Calls the appropriate delivery service based on
notificationTypec. If success:- Marks notification as
COMPLETED - Sets
processingCompletedAt - Logs execution with status:
SUCCESSd. If failure: - Calculates next retry time (exponential backoff)
- Marks as
PENDINGorFAILEDif max retries reached - Logs execution with status:
RETRYorFAILED
- Marks notification as
- Validates notification batch using
DiscordNotificationService.sendEventNotification():
- Checks for duplicates using
NotificationDeduplicator - Formats event message as Discord embed
- Sends POST request to Discord webhook URL
- Returns
trueif webhook responds withok: true,falseotherwise
// Example: Discord notification delivery
await discordService.sendEventNotification(
event,
{ address: "C...", events: ["autoshare_created"] }
);When a notification fails:
markAsFailedOrRetry()calculates the next retry time (exponential backoff)- Sets
nextRetryAttimestamp - Increments
retryCount - Keeps status
PENDINGif retryCount < maxRetries
The RetryScheduler (or main scheduler) will pick it up when nextRetryAt <= now
Notifications reach terminal states when:
COMPLETED: Successfully deliveredFAILED: Exceeded max retriesCANCELLED: Manually cancelled before delivery
These notifications are retained in the database and cleaned up after a retention period (if configured).
Possible Causes:
- Scheduler is disabled (
SCHEDULER_ENABLED=false) - Scheduler is not running
- Notification's
executeAtis in the future - Database is inaccessible
Diagnosis Steps:
- Check scheduler logs:
grep 'Starting notification scheduler' logs/app.log - Check notification status via API:
GET /api/schedule/:id - Check statistics:
GET /api/schedule/stats - Verify database file exists:
ls -la ./data/notifications.db
Possible Causes:
- Worker crashed while processing
- Lock hasn't expired yet
Diagnosis Steps:
- Check if lock is expired:
lockExpiresAt < now - The scheduler automatically recovers stale locks on next poll
- Manual check:
SELECT * FROM scheduled_notifications WHERE status = 'PROCESSING'
Checklist:
- Verify target recipient is valid (e.g., Discord webhook URL works)
- Check
lastErroranderrorDetailsfields - Check
notification_execution_logfor detailed attempt history - Verify the payload is valid for the notification type
Possible Causes:
- Events being redelivered due to blockchain reorganization
- Deduplication window expired
Prevention:
NotificationDeduplicatoruses event ID + contract address to prevent duplicates- Configure appropriate deduplication window
- Check
processed_eventstable to verify
POST /api/schedule
{
"payload": {"message": "Hello!"},
"notificationType": "discord",
"targetRecipient": "https://discord.com/api/webhooks/...",
"executeAt": "2024-12-31T12:00:00Z",
"maxRetries": 3,
"priority": 5,
"metadata": {}
}
GET /api/schedule/:id
POST /api/schedule/:id/cancel
GET /api/schedule/stats
{
"pending": 5,
"processing": 2,
"completed": 100,
"failed": 3,
"overdue": 1
}
CREATE TABLE scheduled_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
notification_type VARCHAR(50) NOT NULL,
target_recipient TEXT NOT NULL,
execute_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
processing_started_at DATETIME,
processing_completed_at DATETIME,
processor_id VARCHAR(100),
lock_expires_at DATETIME,
last_error TEXT,
error_details TEXT,
event_id TEXT,
contract_address TEXT,
priority INTEGER NOT NULL DEFAULT 5,
metadata TEXT,
next_retry_at DATETIME
);idx_scheduled_notifications_status: Fast status queriesidx_scheduled_notifications_status_execute_at: For scheduler to find due notificationsidx_scheduled_notifications_lock_expires: For stale lock recoveryidx_scheduled_notifications_next_retry_at: For retry scheduling
Key environment variables (.env.example in listener):
| Variable | Default | Description |
|---|---|---|
SCHEDULER_ENABLED |
true | Enable/disable scheduler |
DATABASE_PATH |
./data/notifications.db | Database file path |
SCHEDULER_POLL_INTERVAL_MS |
10000 | Poll frequency (ms) |
SCHEDULER_LOCK_TIMEOUT_MS |
60000 | Lock expiration (ms) |
SCHEDULER_BATCH_SIZE |
10 | Notifications per batch |
SCHEDULER_TIMING_BUFFER_MS |
60000 | Timing tolerance (ms) |
SCHEDULER_PROCESSOR_ID |
auto-generated | Unique worker ID |
- NOTIFICATION_LIFECYCLE.md — canonical end-to-end lifecycle
- README-SCHEDULED-NOTIFICATIONS.md
- SCHEDULED-NOTIFICATIONS-DELIVERY.md
- API.md