Base URL: http://localhost:8787 (configured via EVENTS_API_PORT)
Every response carries X-Request-Id (a UUID unique to the request) and X-Correlation-Id (echo of the caller-supplied X-Correlation-Id header, or a server-generated UUID if omitted). Include both when reporting issues.
For a centralized list of API errors, causes, examples, and troubleshooting steps, see API_ERROR_REFERENCE.md.
- Health & Status
- Events
- Scheduled Notifications
- Notification Delivery History
- Notification Search
- Batch Validation
- Notification Templates
- Analytics
- User Notification Preferences
- Webhooks
- Rate Limiting
- Error Codes Reference
Returns the operational status of all service dependencies.
Response 200 — all systems operational (or Discord degraded but Stellar RPC healthy)
{
"status": "ok",
"timestamp": "2024-06-20T14:00:00.000Z",
"services": {
"stellarRpc": { "status": "ok", "latencyMs": 42 },
"discord": { "status": "ok", "latencyMs": 87 },
"eventRegistry": { "status": "ok", "eventCount": 128 }
}
}status is "degraded" when Discord is unreachable but Stellar RPC is healthy:
{
"status": "degraded",
"timestamp": "2024-06-20T14:00:00.000Z",
"services": {
"stellarRpc": { "status": "ok", "latencyMs": 38 },
"discord": { "status": "error", "latencyMs": 5001, "detail": "HTTP 401" },
"eventRegistry": { "status": "ok", "eventCount": 128 }
}
}Response 503 — Stellar RPC is unreachable
{
"status": "error",
"timestamp": "2024-06-20T14:00:00.000Z",
"services": {
"stellarRpc": { "status": "error", "latencyMs": 5001, "detail": "Health check timed out" },
"discord": { "status": "ok", "latencyMs": 65 },
"eventRegistry": { "status": "ok", "eventCount": 128 }
}
}Response 500 — health check itself threw an unexpected error
{ "status": "error", "detail": "Internal health check failure" }A service entry's status can be "ok", "error", or "not_configured". "not_configured" means the service URL was not provided at startup and is not checked.
Returns the pause status of all configured smart contracts.
Response 200
{
"timestamp": "2024-06-20T14:00:00.000Z",
"contracts": [
{ "address": "CCEMX6...", "paused": false },
{ "address": "CCEMX7...", "paused": true, "error": "Failed to simulate contract call" }
]
}| Field | Type | Description |
|---|---|---|
| timestamp | string | ISO 8601 timestamp of when the status was fetched |
| contracts | array | One entry per configured contract |
| address | string | Contract address |
| paused | boolean | Whether the contract is currently paused |
| error | string | Present only when the contract status could not be fetched |
Response 500
{ "status": "error", "detail": "Internal status check failure" }Reports the current indexing lag between the local event registry and the Stellar network tip.
Response 200
{
"status": "synced",
"timestamp": "2024-06-20T14:00:00.000Z",
"indexedLedger": 54321,
"networkTipLedger": 54321,
"ledgerLag": 0,
"processingDelayMs": 850,
"lastIngestedAt": "2024-06-20T13:59:59.000Z",
"detail": null
}| Field | Type | Description |
|---|---|---|
| status | string | "synced", "syncing", or "degraded" |
| indexedLedger | number|null | Ledger sequence of the last ingested event |
| networkTipLedger | number|null | Latest ledger on the Stellar network |
| ledgerLag | number|null | networkTipLedger - indexedLedger (0 = in sync) |
| processingDelayMs | number|null | Milliseconds since the last event was ingested |
| lastIngestedAt | string|null | ISO 8601 timestamp of the last ingested event |
| detail | string|null | Human-readable explanation when status is not "synced" |
Status thresholds:
"synced"—ledgerLag == 0andprocessingDelayMs <= 60 000"syncing"—ledgerLag <= 5andprocessingDelayMs <= 300 000"degraded"— outside the above bounds, ornetworkTipLedgeris unavailable
Returns the last report produced by the notification health monitor.
Response 200
{
"status": "healthy",
"checkedAt": "2024-06-20T14:00:00.000Z",
"pendingCount": 5,
"overdueCount": 0,
"failureRatePercent": 1.2
}Response 503 — health monitor not configured or no report produced yet
{ "error": "Health monitor not configured or no report yet" }Returns all stored contract events, newest first.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| limit | number | No | Maximum number of events to return (default: all) |
Response 200
{
"count": 42,
"events": [
{
"eventId": "0000000000000000-1",
"contractAddress": "CCEMX6JKPEUGYAOU4YZP3WBXGPWK7AEFDEDLRXFIDIJPQFMRXTHUVIO",
"eventName": "autoshare_created",
"ledger": 12345,
"type": "contract",
"topic": ["autoshare_created", "GABC..."],
"value": "AAAAAQ==",
"txHash": "abc123...",
"receivedAt": 1718640000000
}
]
}| Field | Type | Description |
|---|---|---|
| eventId | string | Soroban event ID (ledger-index format) |
| contractAddress | string | Contract that emitted the event |
| eventName | string|null | Decoded event name (first topic symbol), if decodeable |
| ledger | number | Ledger sequence containing the event |
| type | string | Always "contract" for Soroban events |
| topic | string[] | Decoded event topics |
| value | string | Base64-encoded XDR event data |
| txHash | string | Transaction hash |
| receivedAt | number | Unix timestamp (ms) when the listener received the event |
Preferences control which notification categories are delivered per user. Categories default to enabled when not explicitly set.
Returns the notification preferences for a user.
Path Parameters
| Name | Description |
|---|---|
| userId | User identifier |
Response 200
{
"userId": "alice",
"categories": {
"discord": true
},
"updatedAt": 1718640000000
}Updates one or more notification category flags for a user. Unspecified categories are preserved.
Path Parameters
| Name | Description |
|---|---|
| userId | User identifier |
Request Body
{
"categories": {
"discord": false
}
}| Field | Type | Required | Description |
|---|---|---|---|
| categories | Record<string, boolean> |
Yes | Map of category name to enabled flag |
Response 200 — returns the full updated preferences object.
{
"userId": "alice",
"categories": {
"discord": false
},
"updatedAt": 1718640100000
}Response 400 — returned when the request body is invalid JSON or the categories field is missing.
{ "error": "Invalid body: expected { categories: { [key]: boolean } }" }Note: Available notification categories include
discordand any custom categories defined in your deployment. Categories default to enabled when not explicitly set.
Per-contract user binding: To apply a user's preferences to events from a specific contract, set
userIdin the contract address config:{ "address": "CCEMX6...", "events": ["*"], "userId": "alice" }. IfuserIdis omitted, the"global"user's preferences apply.
{ "error": "Failed to insert notification into database" }Response 503 — scheduler feature is disabled
{ "error": "Scheduler not enabled" }Returns a single scheduled notification by its numeric ID.
Path Parameters
| Name | Description |
|---|---|
| id | Notification ID (integer) |
Response 200
Payload size limit: The
payloadobject is serialised to JSON before storage. The resulting byte length must not exceed the configured maximum (default 64 KB / 65 536 bytes). Oversized payloads are rejected with HTTP413. Override the limit at runtime with theMAX_PAYLOAD_SIZE_BYTESenvironment variable.
Request Body
{
"id": 42,
"executeAt": "2024-06-20T15:00:00.000Z",
"payload": { "content": "Your task was completed." },
"targetRecipient": "https://discord.com/api/webhooks/...",
"notificationType": "discord",
"status": "pending",
"retries": 0,
"maxRetries": 3,
"priority": 1,
"eventId": "abc123",
"contractAddress": "CCEMX6...",
"metadata": null,
"createdAt": 1718640000000
}Response 400 — non-numeric :id
| Field | Type | Required | Description |
|---|---|---|---|
| executeAt | string | Yes | ISO 8601 datetime — when to deliver the notification |
| payload | object | Yes | Arbitrary data forwarded to the notification handler. Serialised JSON must not exceed MAX_PAYLOAD_SIZE_BYTES (default 64 KB). |
| targetRecipient | string | Yes | Delivery target (e.g. Discord webhook URL) |
| notificationType | string | No | "discord" (default) |
| maxRetries | number | No | Override max retry count |
| priority | number | No | Lower numbers run first |
| eventId | string | No | Correlation ID linking this to a contract event |
| contractAddress | string | No | Contract that triggered the notification |
| metadata | object | No | Arbitrary key/value metadata |
Response 201
{ "id": 42 }Response 400 — missing required fields
{ "error": "Invalid notification ID" }Response 404 — no notification with that ID
{ "error": "Notification not found" }Response 500 — database read failure
Response 413 — payload exceeds the maximum allowed size
{ "error": "Notification payload is too large: 70000 bytes exceeds the 65536-byte limit. Reduce the payload size and retry." }Response 500 — internal scheduling failure
{ "error": "SQLITE_ERROR: ..." }Response 503 — scheduler feature is disabled
{ "error": "Scheduler not enabled" }Returns aggregate statistics about the scheduled-notification queue.
Response 200
{
"pending": 15,
"processing": 3,
"completed": 1234,
"failed": 45,
"overdue": 2
}| Field | Type | Description |
|---|---|---|
| pending | number | Notifications waiting to be processed |
| processing | number | Notifications currently being processed |
| completed | number | Successfully delivered notifications |
| failed | number | Notifications that permanently failed after exhausting retries |
| overdue | number | Pending notifications that are past their executeAt time |
Response 500 — database read failure
{ "error": "SQLITE_ERROR: ..." }Response 503 — scheduler feature is disabled
{ "error": "Scheduler not enabled" }Returns deduplicated delivery performance metrics. Each notification is counted exactly once regardless of how many retry attempts it took, preventing double-counting.
Response 200
{
"totalNotifications": 100,
"successfulFirstAttempt": 70,
"successfulAfterRetry": 20,
"permanentFailures": 10,
"totalRetryAttempts": 35,
"averageRetriesPerNotification": 0.35,
"averageSuccessDurationMs": 845.5,
"averageFailureDurationMs": 2341.2
}| Field | Type | Description |
|---|---|---|
| totalNotifications | number | Completed or permanently failed notifications (one count per notification ID) |
| successfulFirstAttempt | number | Delivered successfully on the first attempt (zero retries) |
| successfulAfterRetry | number | Delivered successfully after one or more retries |
| permanentFailures | number | Failed permanently after exhausting all retries |
| totalRetryAttempts | number | Sum of retry counts across all notifications |
| averageRetriesPerNotification | number | totalRetryAttempts / totalNotifications |
| averageSuccessDurationMs | number | Average duration (ms) of the final successful delivery attempt |
| averageFailureDurationMs | number | Average duration (ms) of the final failed delivery attempt |
Computing success rate:
const successRate =
(metrics.successfulFirstAttempt + metrics.successfulAfterRetry) /
metrics.totalNotifications;
// Example: (70 + 20) / 100 = 0.90Response 500 — database read failure
{ "error": "SQLITE_ERROR: ..." }Response 503 — scheduler feature is disabled
{ "error": "Scheduler not enabled" }Returns a breakdown of final outcomes grouped by retry count. Useful for tuning retry policies.
Response 200
[
{ "retryCount": 0, "successCount": 70, "failureCount": 0 },
{ "retryCount": 1, "successCount": 15, "failureCount": 2 },
{ "retryCount": 2, "successCount": 5, "failureCount": 3 },
{ "retryCount": 3, "successCount": 0, "failureCount": 5 }
]| Field | Type | Description |
|---|---|---|
| retryCount | number | Number of retries before the final outcome |
| successCount | number | Notifications that succeeded after exactly retryCount retries |
| failureCount | number | Notifications that failed after exactly retryCount retries |
Response 500 — database read failure
{ "error": "SQLITE_ERROR: ..." }Response 503 — scheduler feature is disabled
{ "error": "Scheduler not enabled" }Returns paginated delivery execution records from notification_execution_log.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| limit | number | No | Maximum records per page (default 20, max 100) |
| offset | number | No | Number of records to skip (default 0). Prefer cursor. |
| cursor | string | No | Opaque token for cursor-based pagination |
| status | string | No | Filter by execution status: SUCCESS, FAILED, or RETRY |
| startDate | string | No | ISO 8601 lower bound on execution_time (inclusive) |
| endDate | string | No | ISO 8601 upper bound on execution_time (inclusive) |
Response 200
{
"records": [
{
"id": 1,
"scheduledNotificationId": 42,
"executionAttempt": 1,
"executionTime": "2024-06-20T15:00:00.000Z",
"status": "SUCCESS",
"errorMessage": null,
"responseDuration": 120
}
],
"total": 5,
"itemCount": 5,
"totalPages": 3,
"limit": 2,
"offset": 0,
"nextCursor": "MjAyNC0wNi0yMFQxNTowMDowMC4wMDBaLDQy"
}| Field | Type | Description |
|---|---|---|
| records | array | Execution log entries for the current page |
| total | number | Total matching records (preserved for backward compatibility; same value as itemCount) |
| itemCount | number | Total number of records matching the query filters |
| totalPages | number | Total pages available at the requested limit (0 when itemCount is 0) |
| limit | number | Effective page size applied to the query |
| offset | number | Number of records skipped before this page |
| nextCursor | string | Opaque token to fetch the next page of results |
Existing clients that read total, limit, offset, and records continue to work unchanged. New clients should prefer itemCount and totalPages for pagination UI.
Response 500 — database read failure
{ "error": "SQLITE_ERROR: ..." }Full-text and field-based search across scheduled notifications.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| q | string | No | Free-text search across payload, metadata, and recipient fields |
| sender | string | No | Filter by sender address or identifier |
| txHash | string | No | Filter by originating transaction hash |
| eventId | string | No | Filter by correlated contract event ID |
| status | string | No | Filter by status: PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED |
| type | string | No | Filter by notification type: discord, email, webhook, sms |
| limit | number | No | Maximum results to return (default: 20) |
| offset | number | No | Number of results to skip (default: 0) |
Response 200
{
"results": [
{
"id": 42,
"payload": { "content": "Your task completed." },
"notificationType": "discord",
"targetRecipient": "https://discord.com/api/webhooks/...",
"executeAt": "2024-06-20T15:00:00.000Z",
"status": "COMPLETED",
"eventId": "abc123",
"contractAddress": "CCEMX6...",
"createdAt": "2024-06-20T14:00:00.000Z"
}
],
"total": 150,
"limit": 20,
"offset": 0
}Response 500
{ "error": "..." }Returns autocomplete suggestions based on a partial query string. Useful for building search UIs.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| q | string | Yes | Partial search string (minimum 1 character) |
| limit | number | No | Maximum suggestions to return (default: 10) |
Response 200
{
"suggestions": [
"task_completed",
"task_created",
"transfer_complete"
]
}Response 500
{ "error": "..." }Validates a batch of notification objects without persisting them. Useful for pre-flight checks before bulk scheduling.
Request Body
Accepts either a JSON array of notification objects directly, or a wrapper object:
[
{
"payload": { "content": "Message 1" },
"targetRecipient": "https://discord.com/api/webhooks/...",
"executeAt": "2024-06-20T15:00:00.000Z",
"notificationType": "discord"
},
{
"payload": { "content": "Message 2" },
"targetRecipient": "https://discord.com/api/webhooks/...",
"executeAt": "invalid-date"
}
]Or with a wrapper key:
{
"notifications": [ ... ]
}Response 200 — all items passed validation
{
"valid": true,
"processedCount": 2,
"errors": []
}Response 400 — one or more items failed validation
{
"valid": false,
"processedCount": 2,
"errors": [
{
"index": 1,
"code": "INVALID_DATE",
"message": "executeAt is not a valid ISO 8601 date"
}
]
}| Field | Type | Description |
|---|---|---|
| valid | boolean | true only when zero validation errors were found |
| processedCount | number | Total items evaluated |
| errors | array | One entry per failed item; empty array when valid is true |
| errors[].index | number | Zero-based position of the failing item in the input array (-1 for parse errors) |
| errors[].code | string | Machine-readable error code (e.g. INVALID_DATE, MISSING_FIELD, PARSE_ERROR) |
| errors[].message | string | Human-readable description |
Response 400 — request body is not valid JSON
{
"valid": false,
"processedCount": 0,
"errors": [{ "index": -1, "code": "PARSE_ERROR", "message": "Request body must be valid JSON." }]
}Templates allow reusable message bodies with variable substitution. The template service must be enabled at startup; all template endpoints return 503 when it is not.
Lists all templates, with optional filtering.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| channelType | string | No | Filter by channel type (e.g. "discord", "email") |
| activeOnly | boolean | No | When true, return only active templates |
Response 200
{
"count": 3,
"templates": [
{
"id": 1,
"uniqueKey": "task-completed",
"name": "Task Completed",
"description": "Sent when a task bounty is completed.",
"channelType": "discord",
"subjectTemplate": null,
"bodyTemplate": "Task {{taskId}} has been completed by {{contributor}}.",
"variables": ["taskId", "contributor"],
"defaultValues": {},
"isActive": true,
"createdBy": "admin",
"createdAt": "2024-06-01T00:00:00.000Z",
"updatedAt": "2024-06-01T00:00:00.000Z"
}
]
}Creates a new notification template.
Request Body
{
"uniqueKey": "task-completed",
"name": "Task Completed",
"description": "Sent when a task bounty is completed.",
"channelType": "discord",
"bodyTemplate": "Task {{taskId}} has been completed by {{contributor}}.",
"subjectTemplate": null,
"variables": ["taskId", "contributor"],
"defaultValues": {},
"createdBy": "admin"
}| Field | Type | Required | Description |
|---|---|---|---|
| uniqueKey | string | Yes | URL-safe unique identifier for this template |
| name | string | Yes | Human-readable display name |
| channelType | string | Yes | Target channel: "discord", "email", "webhook", "sms" |
| bodyTemplate | string | Yes | Handlebars-style template body with {{variable}} placeholders |
| description | string | No | Optional description |
| subjectTemplate | string | No | Subject line template (relevant for email) |
| variables | string[] | No | List of variable names used in the template |
| defaultValues | object | No | Default values for variables |
| createdBy | string | No | Identifier of the user creating the template |
Response 201
{ "id": 1, "uniqueKey": "task-completed" }Response 400 — missing required fields
{ "error": "Missing required fields", "required": ["uniqueKey", "name", "channelType", "bodyTemplate"] }Response 409 — uniqueKey already exists
{ "error": "Template with this unique key already exists" }Returns a template by its numeric ID.
Path Parameters
| Name | Description |
|---|---|
| id | Template ID (integer) |
Response 200 — template object (same shape as items in the list response)
Response 400 — non-numeric :id
{ "error": "Invalid template ID" }Response 404
{ "error": "Template not found" }Returns a template by its uniqueKey.
Path Parameters
| Name | Description |
|---|---|
| uniqueKey | The template's unique key |
Response 200 — template object
Response 404
{ "error": "Template not found" }Updates an existing template. Only the supplied fields are changed.
Path Parameters
| Name | Description |
|---|---|
| id | Template ID (integer) |
Request Body — any subset of the template fields:
{
"bodyTemplate": "Task {{taskId}} was completed. Reward: {{reward}} XLM.",
"variables": ["taskId", "reward"]
}Response 200
{ "id": 1, "message": "Template updated successfully" }Response 404
{ "error": "Template not found" }Deactivates a template (soft-delete by default). Pass ?hard=true to permanently delete.
Path Parameters
| Name | Description |
|---|---|
| id | Template ID (integer) |
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| hard | boolean | No | When true, permanently deletes the template record |
Response 200
{ "id": 1, "message": "Template deactivated" }Or when hard=true:
{ "id": 1, "message": "Template deleted permanently" }Response 404
{ "error": "Template not found" }Renders a template by substituting variables with provided context values.
Request Body
{
"templateId": 1,
"context": {
"taskId": "TASK-99",
"contributor": "GABC1234...XYZ"
}
}| Field | Type | Required | Description |
|---|---|---|---|
| templateId | number | No* | Template ID. Provide either templateId or uniqueKey. |
| uniqueKey | string | No* | Template unique key. Provide either templateId or uniqueKey. |
| context | object | Yes | Key-value map of variable names to substitution values |
* One of templateId or uniqueKey is required.
Response 200
{
"subject": null,
"body": "Task TASK-99 has been completed by GABC1234...XYZ."
}Response 400 — missing required fields
{ "error": "Missing required fields", "required": ["templateId OR uniqueKey", "context"] }Response 404
{ "error": "Template not found" }Returns usage statistics for all templates, or for a specific template.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| templateId | number | No | Limit stats to a specific template |
Response 200
{
"totalTemplates": 5,
"activeTemplates": 4,
"renderCount": 1234,
"byTemplate": [
{ "id": 1, "uniqueKey": "task-completed", "renderCount": 800, "lastRenderedAt": "2024-06-20T14:00:00.000Z" }
]
}Returns the full audit history for a specific template (create, update, delete events).
Path Parameters
| Name | Description |
|---|---|
| id | Template ID or unique key |
Response 200
{
"templateId": "task-completed",
"records": [
{
"action": "created",
"actor": "admin",
"timestamp": "2024-06-01T00:00:00.000Z",
"changes": {}
},
{
"action": "updated",
"actor": "admin",
"timestamp": "2024-06-10T12:00:00.000Z",
"changes": { "bodyTemplate": "..." }
}
]
}Response 404
{ "error": "Template not found" }Returns a real-time aggregated snapshot of notification delivery metrics. Pass ?reset=true to atomically read and reset the counters.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| reset | boolean | No | If true, resets the aggregator after reading |
Response 200
{
"totalRecorded": 1500,
"successCount": 1350,
"failureCount": 100,
"retryCount": 50,
"byType": {
"discord": { "success": 900, "failure": 60 },
"email": { "success": 450, "failure": 40 }
},
"buckets": [
{ "startMs": 1718640000000, "endMs": 1718640060000, "count": 25 }
]
}Response 503 — analytics aggregator not available
{ "error": "Analytics aggregator unavailable" }Returns historical analytics snapshots persisted by the metrics store. Supports time-range filtering.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| limit | number | No | Maximum snapshots to return (default: 50, max: 100) |
| since | string | No | ISO 8601 lower bound — return only snapshots after this time |
Response 200
{
"snapshots": [
{
"capturedAt": "2024-06-20T14:00:00.000Z",
"totalRecorded": 1500,
"successCount": 1350,
"failureCount": 100
}
]
}Response 503 — metrics store not configured
{ "error": "Metrics history store unavailable" }Receives a signed webhook event payload. The request must carry a valid HMAC-SHA256 signature produced with a pre-shared secret.
Required Headers
| Header | Description |
|---|---|
X-Signature |
HMAC-SHA256 hex digest of the raw request body |
X-Key-Id |
Identifier selecting which secret to verify with |
Response 202
{ "status": "accepted" }Response 400 — request body could not be read
{ "error": "Failed to read request body" }Response 401 — X-Signature header absent
{ "error": "Missing signature header" }Response 401 — X-Key-Id header absent
{ "error": "Missing key-id header" }Response 401 — X-Key-Id value does not match any registered secret
{ "error": "Unknown key-id" }Response 401 — signature does not match the computed HMAC
{ "error": "Invalid signature" }The API enforces configurable rate limits to protect against abuse. Limits can be set globally or per-client using API keys or IP addresses.
All responses include these headers when rate limiting is enabled:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed in the current window |
X-RateLimit-Remaining |
Requests remaining before hitting the limit |
X-RateLimit-Reset |
Unix timestamp (seconds) when the window resets |
When a rate limit is exceeded:
| Header | Description |
|---|---|
Retry-After |
Seconds to wait before retrying the request |
Returns real-time rate limiting statistics for monitoring and analysis.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| reset | boolean | No | If true, resets metrics after reading them |
Response 200
{
"totalRequests": 1543,
"blockedRequests": 87,
"allowedRequests": 1456,
"uniqueClients": 23,
"topBlockedClients": [
{
"clientId": "192.168.1.100",
"blockCount": 45
},
{
"clientId": "sk_live_...",
"blockCount": 23
}
],
"startTime": "2024-01-01T12:00:00.000Z"
}| Field | Type | Description |
|---|---|---|
| totalRequests | number | Total requests processed since server start or last reset |
| blockedRequests | number | Requests that were rate limited |
| allowedRequests | number | Requests that were allowed through |
| uniqueClients | number | Number of distinct clients currently tracked |
| topBlockedClients | array | Top 10 clients by block count (API keys are masked) |
| startTime | string | ISO 8601 timestamp when metrics tracking started |
Response 503 — rate limiting is disabled
{ "error": "Rate limiting not enabled" }Example — fetch metrics
GET /api/rate-limit/metrics HTTP/1.1HTTP/1.1 200 OK
Content-Type: application/json
{
"totalRequests": 1543,
"blockedRequests": 87,
"allowedRequests": 1456,
"uniqueClients": 23,
"topBlockedClients": [
{ "clientId": "192.168.1.100", "blockCount": 45 }
],
"startTime": "2024-01-01T12:00:00.000Z"
}Example — fetch and reset metrics
GET /api/rate-limit/metrics?reset=true HTTP/1.1HTTP/1.1 200 OK
Content-Type: application/json
{
"totalRequests": 1543,
"blockedRequests": 87,
"allowedRequests": 1456,
"uniqueClients": 23,
"topBlockedClients": [],
"startTime": "2024-01-01T12:00:00.000Z"
}Returns the operational status of all service dependencies.
Response 200 — all systems operational or non-critical services degraded
{
"status": "ok",
"timestamp": "2024-06-20T14:00:00.000Z",
"services": {
"stellarRpc": { "status": "ok", "latencyMs": 42 },
"discord": { "status": "ok", "latencyMs": 87 },
"database": { "status": "ok", "latencyMs": 3 },
"eventRegistry": { "status": "ok", "eventCount": 128 }
}
}status is "degraded" when Discord is unreachable but Stellar RPC and the database are healthy:
{
"status": "degraded",
"timestamp": "2024-06-20T14:00:00.000Z",
"services": {
"stellarRpc": { "status": "ok", "latencyMs": 38 },
"discord": { "status": "error", "latencyMs": 5001, "detail": "HTTP 401" },
"database": { "status": "ok", "latencyMs": 2 },
"eventRegistry": { "status": "ok", "eventCount": 128 }
}
}Response 503 — Stellar RPC or the SQLite database is unreachable
{
"status": "error",
"timestamp": "2024-06-20T14:00:00.000Z",
"services": {
"stellarRpc": { "status": "error", "latencyMs": 5001, "detail": "Health check timed out" },
"discord": { "status": "ok", "latencyMs": 65 },
"database": { "status": "ok", "latencyMs": 2 },
"eventRegistry": { "status": "ok", "eventCount": 128 }
}
}Response 500 — health check itself threw an unexpected error
{ "status": "error", "detail": "Internal health check failure" }A service entry's status field can be "ok", "error", or "not_configured". "not_configured" means the service URL was not provided at startup and is not checked.
Returns the pause status of all configured smart contracts.
Response 200
{
"timestamp": "2024-06-20T14:00:00.000Z",
"contracts": [
{
"address": "CCEMX6...",
"paused": false
},
{
"address": "CCEMX7...",
"paused": true,
"error": "Failed to simulate contract call"
}
]
}| Field | Type | Description |
|---|---|---|
| timestamp | string | ISO 8601 timestamp of when the status was fetched |
| contracts | array | List of contracts and their statuses |
| address | string | Contract address |
| paused | boolean | Whether the contract is currently paused |
| error | string | Optional. Error message if we could not fetch the status for this contract |
Response 500 — internal error fetching status
{ "status": "error", "detail": "Internal status check failure" }Every error response is a JSON object. All errors carry an error string. Rate-limit responses also include a message field; health-check failures use detail instead.
{ "error": "Human-readable description of what went wrong" }Every response — success or error — includes the following tracing headers:
| Header | Description |
|---|---|
X-Request-Id |
Unique ID generated for this request |
X-Correlation-Id |
Caller-supplied or server-generated trace ID |
| HTTP Code | Name | When it occurs | Client action |
|---|---|---|---|
| 200 | OK | Request succeeded; body contains the result | Consume response body |
| 201 | Created | Resource created (scheduled notification) | Persist the returned id for future lookups |
| 202 | Accepted | Webhook accepted for processing | No further action needed |
| 204 | No Content | CORS preflight (OPTIONS) succeeded |
Browser handles automatically |
| 400 | Bad Request | Client sent invalid input — see error message for the specific field | Fix the request body or parameters before retrying |
| 401 | Unauthorized | Webhook signature is missing, unrecognised, or invalid | Verify the signing secret and key ID; regenerate the HMAC |
| 404 | Not Found | Route or resource does not exist | Check the URL and resource ID |
| 429 | Too Many Requests | Client exceeded its rate limit for the current window | Wait Retry-After seconds before sending the next request |
| 500 | Internal Server Error | Unhandled server-side failure | Retry with exponential backoff; report if persistent |
| 503 | Service Unavailable | Scheduler is disabled, or Stellar RPC is unreachable | Check server configuration or wait for dependency to recover |
The request was rejected because client-supplied data failed validation.
| Error message | Endpoint | Cause | Fix |
|---|---|---|---|
"Invalid body: expected { categories: { [key]: boolean } }" |
PUT /api/preferences/:userId |
Body is valid JSON but categories field is absent or not an object |
Send { "categories": { "discord": true } } |
"Invalid JSON" |
PUT /api/preferences/:userId |
Body is not parseable JSON | Ensure Content-Type: application/json and well-formed JSON |
"Missing required fields: executeAt, payload, targetRecipient" |
POST /api/schedule |
One or more of the three required fields is absent | Include all three fields in the request body |
"executeAt is not a valid date" |
POST /api/schedule |
executeAt string cannot be parsed as a JavaScript Date |
Use a valid ISO 8601 string, e.g. "2024-06-20T15:00:00.000Z" |
"Invalid notification ID" |
GET /api/schedule/:id |
:id path segment is not a valid integer |
Use a numeric ID returned by POST /api/schedule |
"Failed to read request body" |
POST /api/webhooks |
The connection was dropped while reading the body | Resend the request with the full body intact |
Example — missing schedule fields
POST /api/schedule HTTP/1.1
Content-Type: application/json
{ "payload": { "content": "hello" } }HTTP/1.1 400 Bad Request
Content-Type: application/json
X-Request-Id: f3a2c1b0-...
{ "error": "Missing required fields: executeAt, payload, targetRecipient" }All 401 errors come from the POST /api/webhooks endpoint when signature verification fails. The request must carry both X-Signature and X-Key-Id headers.
| Error message | Cause | Fix |
|---|---|---|
"Missing signature header" |
X-Signature header is absent |
Add the header with an HMAC-SHA256 hex digest of the raw request body |
"Missing key-id header" |
X-Key-Id header is absent |
Add the header with the ID of the signing key used |
"Unknown key-id" |
The X-Key-Id value does not match any key registered with the server |
Use a key ID that the server was started with |
"Invalid signature" |
The signature does not match the server's HMAC computation | Re-sign the raw body bytes with the correct secret; ensure no encoding transformation is applied to the body in transit |
Example — wrong secret
POST /api/webhooks HTTP/1.1
X-Signature: aabbccdd...
X-Key-Id: key-prod-1
Content-Type: application/json
{ "event": "TaskCreated", "contractAddress": "CCEMX6..." }HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: d9e1f2a3-...
{ "error": "Invalid signature" }| Error message | When | Fix |
|---|---|---|
"Not found" |
The request path does not match any known route | Check the URL against this document |
"Notification not found" |
GET /api/schedule/:id — no notification exists with the given ID |
Verify the ID was returned by POST /api/schedule and has not been purged |
Returned by the rate limiter when a client exceeds the configured request quota within the sliding window.
The response always includes error and message. Response headers tell the client exactly when to retry.
Response headers
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed in the current window |
X-RateLimit-Remaining |
Requests still available (0 when limited) |
X-RateLimit-Reset |
Unix timestamp (seconds) when the oldest slot leaves the window |
Retry-After |
Seconds to wait before the next request will be accepted |
Client identification — the limiter tracks clients by API key first, then by IP address:
X-API-Keyheader valueAuthorization: Bearer <token>tokenX-Forwarded-Forfirst IP- TCP remote address
Example
GET /api/events HTTP/1.1
X-API-Key: my-key
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1718640060
Retry-After: 12
X-Request-Id: 8b4c3d2e-...
{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Try again in 12 seconds."
}Rate limit violations are recorded in the SQLite database for audit purposes.
Returned when the server encounters an unexpected failure. The error field contains the original exception message.
| Endpoint | Likely cause |
|---|---|
POST /api/schedule |
Database write failure; SQLite locked or corrupt |
GET /api/schedule/:id |
Database read failure |
GET /api/schedule/stats |
Database read failure |
GET /health |
Unexpected exception in the health check loop |
Example
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
X-Request-Id: 1c2d3e4f-...
{ "error": "SQLITE_BUSY: database is locked" }Retry with exponential backoff. If the error persists, inspect the server logs using the X-Request-Id from the response to find the full stack trace.
The /health endpoint returns a distinct shape on its own internal failure:
{ "status": "error", "detail": "Internal health check failure" }Returned in two distinct situations.
Scheduler disabled
POST /api/schedule, GET /api/schedule/:id, and GET /api/schedule/stats all return 503 when the scheduler was not enabled at startup (i.e. the notificationAPI option was not provided).
{ "error": "Scheduler not enabled" }This is a configuration issue, not a transient failure. Retrying will not help until the service is restarted with the scheduler enabled.
Stellar RPC unreachable
GET /health returns 503 when the Stellar RPC node cannot be reached or times out (5-second timeout). The body is the full health object with "status": "error":
{
"status": "error",
"timestamp": "2024-06-20T14:00:00.000Z",
"services": {
"stellarRpc": {
"status": "error",
"latencyMs": 5001,
"detail": "Health check timed out"
},
"discord": { "status": "ok", "latencyMs": 54 },
"eventRegistry": { "status": "ok", "eventCount": 200 }
}
}Wait for the upstream Stellar RPC node to recover, or point STELLAR_RPC_URL at a healthy node and restart the service.
Every response carries X-Request-Id (a UUID unique to that request) and X-Correlation-Id (a caller-supplied or server-generated trace ID). Include both values when reporting issues.
Server-side logs are keyed on the same IDs:
[WARN] requestId=8b4c3d2e correlationId=front-42 Webhook missing signature header
[ERROR] requestId=1c2d3e4f correlationId=front-43 Failed to schedule notification { error: "SQLITE_BUSY" }
To propagate a trace ID from your own service, send X-Correlation-Id in the request. The server echoes it back in the response header.