Why Signal exists in bot architectures
Signal was designed for teams that run bots in real operations where multiple external services need to inform the bot about domain changes without creating hard runtime dependencies. Instead of wiring each API, worker, or admin panel directly into your bot internals, you publish standardized events through Discord webhooks and let the bot consume them through one secure path. This keeps ownership boundaries clean because external systems focus on producing business events while the bot focuses on reacting to them.
The protocol uses Discord's own webhook infrastructure as the transport layer. External services send signed JSON envelopes to a private Discord channel, and the bot's Signal module listens for new messages on that channel. This design avoids exposing internal service APIs, avoids setting up custom transport servers, and leverages Discord's existing reliability and delivery guarantees. The separation between producer and consumer is the core reason Signal scales better than ad-hoc webhook handlers scattered across feature modules.
Two packages, one protocol
The Signal ecosystem consists of two packages. @spraxium/signal
is the bot-side receiver that validates and dispatches events.
@spraxium/signal-client
is the lightweight emitter used by external services to publish signed
envelopes.
Architecture overview
The Signal ecosystem splits into a receiver (the bot) and one or more emitters (external services). The emitter builds a signed envelope, posts it as a webhook message to a private Discord channel, and the bot's listener picks it up. The receiver validates the envelope through a strict ten-rule pipeline before dispatching the event to a registered handler.
This architecture keeps the bot process clean. It never exposes incoming HTTP ports for event ingestion. Instead, it leverages Discord's existing messageCreate event, which the bot already subscribes to. The Signal module simply adds a filter that identifies webhook messages from approved sources and routes them through the validation pipeline.
| Component | Package | Responsibility |
|---|---|---|
| SignalClient | @spraxium/signal-client | Builds envelopes, signs them with HMAC, delivers via webhook POST. |
| SignalModule | @spraxium/signal | Global module that wires the receiver pipeline into the bot runtime. |
| SignalValidator | @spraxium/signal | Executes ten sequential validation rules on incoming messages. |
| SignalProcessor | @spraxium/signal | Orchestrates validation, nonce dedup, Zod parsing, and handler dispatch. |
| SignalRouter | @spraxium/signal | Maps event names to registered handler functions. |
| SignalRegistry | @spraxium/signal | Discovers @SignalListener classes, registers handlers, and wires the messageCreate listener. |
| NonceCache | @spraxium/signal | Tracks processed nonces with a 60-second TTL for replay protection. |
| HmacService | @spraxium/signal | Computes and verifies HMAC-SHA256 signatures with timing-safe comparison. |
Recommended folder structure
The envelope contract
Every Signal message follows a single envelope shape. This standardized structure ensures that transport validation happens before any business code runs. The envelope is a JSON object with seven fields, each serving a specific role in the validation and dispatch pipeline.
| Field | Type | Purpose |
|---|---|---|
| v | 1 | Protocol version used for compatibility control. Always |
| event | string | Domain event name that maps to a bot-side listener (e.g.,
|
| guildId | string | Target guild context for multi-guild bot deployments. |
| nonce | string | UUID unique to this envelope, used for replay deduplication. |
| sentAt | number | Unix timestamp in milliseconds when the envelope was created. |
| signature | string | HMAC-SHA256 hex digest for integrity and source trust verification. |
| payload | unknown | Free-form domain data delivered to the resolved bot handler. |
{
"v": 1,
"event": "config.update",
"guildId": "123456789012345678",
"nonce": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"sentAt": 1712937600000,
"signature": "8f14e45fceea167a5a36dedd4bea2543",
"payload": {
"prefix": "!",
"language": "en"
}
}HMAC security model
The HMAC layer is the security boundary that tells the bot whether an envelope was produced by an authorized emitter and whether the content stayed intact in transit. The signature is computed by concatenating the nonce, sentAt, and payload fields into a single string (${nonce}.${sentAt}.${JSON.stringify(payload)}), then hashing it with HMAC-SHA256 using a shared secret key.
On the bot side, the same computation is performed and the result is compared to the received signature using a timing-safe comparison function. If the values do not match, the envelope is rejected before any business logic executes. This protects against three categories of attacks: payload tampering in transit, replay with modified data, and envelopes forged by unauthorized services.
In practical terms, HMAC allows your team to trust automation coming from external dashboards, billing systems, or moderation backoffices without exposing internal service APIs to the bot process. The shared secret must be the same on both the emitter and receiver side, and it should never be committed to version control.
Shared secret must match exactly
The hmacSecret value in defineSignal on the bot side
must be identical to the hmacSecret value in the
SignalClient constructor. If they diverge even by one character,
every envelope will be rejected as untrusted.
Ten-rule validation pipeline
Before any domain handler executes, every incoming message passes through a strict ten-rule validation pipeline implemented in SignalValidator. Each rule is evaluated sequentially, and the first failure stops the pipeline immediately. This fail-fast design ensures that invalid messages consume minimal processing time.
| Rule | Check | Rejects if |
|---|---|---|
| R1 | Webhook source | The message does not have a webhookId property. |
| R2 | Webhook identity | The webhookId is not in the allowedWebhookIds array. |
| R3 | Channel boundary | The message channel does not match the configured channelId. |
| R4 | JSON parsing | The message content is not valid JSON or is not an object. |
| R5 | Protocol version | The v field is not exactly 1. |
| R6 | Event registration | The event name is not registered with any bot-side handler. |
| R7 | Guild ID validity | The guildId is empty or not a string. |
| R8 | Nonce validity | The nonce is empty or not a string. |
| R9 | Clock skew | The sentAt timestamp deviates more than 5 minutes from the bot's clock. |
| R10 | HMAC signature | The signature does not match the recomputed HMAC-SHA256 digest. |
Full processing pipeline
After the validator approves an envelope, the SignalProcessor continues with three additional steps before the domain handler executes. Understanding the complete pipeline helps you debug delivery issues and reason about the order of operations during incident analysis.
Ten-rule validation
The SignalValidator checks every incoming message against ten sequential rules. The first failure rejects the message and logs a diagnostic entry when debug mode is enabled. Only messages that pass all ten rules proceed to the next stage.
Nonce deduplication
The NonceCache checks whether this nonce was already seen within the last 60 seconds. If it was, the message is treated as a replay and silently dropped. If it is new, the nonce is recorded with a timestamp. Expired entries are evicted automatically on the next check.
Handler resolution and routing
The SignalRouter looks up the handler registered for this event name. If no handler exists, the message is logged as unroutable and dropped. This should not happen because rule R6 already verified the event was registered, but the router check adds defense in depth.
Zod payload validation
If the resolved handler has a Zod schema attached via the @OnSignal options, the processor runs safeParse on the payload. If validation fails, the message is rejected with a diagnostic log. If no schema is configured, the raw payload is passed through unchanged.
Optional message deletion
If deleteAfterProcessing is enabled in the Signal config, the processor attempts to delete the webhook message from the Discord channel. Deletion failures are logged as warnings but do not block handler execution.
Handler execution
The processor invokes the resolved handler with the validated payload and the full envelope. Errors thrown by the handler are caught, logged, and do not crash the Signal pipeline. Other events continue to be processed normally.
Practical use cases
| Use case | Fit | Why it works well |
|---|---|---|
| Billing system notifying role updates | Strong | Business systems emit events while the bot applies guild-side role changes safely and asynchronously. |
Moderation backoffice publishing enforcement actions | Strong | Backoffice stays decoupled and the bot handles Discord-facing execution independently. |
| Next.js admin panel using Server Actions | Strong | Server Actions publish signed events directly, keeping UI logic simple and bot operations asynchronous. |
| CI/CD pipeline triggering bot announcements | Strong | Pipeline steps emit events after successful deploys and the bot posts formatted release notes. |
| Synchronous request-response workflows | Weak | Signal is async and unidirectional by design. Direct HTTP contracts are better for immediate responses. |
When to avoid Signal
If the producer and consumer live in the same module graph and you do not need asynchronous boundaries, direct service calls are simpler to reason about and easier to trace. Signal should be adopted when its operational guarantees, security model, and decoupling benefits are genuinely useful, not as a default for every internal interaction. For synchronous user-facing feedback loops where the caller needs an immediate response, HTTP endpoints or direct Discord API calls remain the better choice.