A fast, non-blocking webhook dispatch crate with a handlebars-powered template engine. Built for high-frequency trading platforms where the dispatch path must never block the main thread.
- Non-blocking dispatch β
sendpushes to akanalchannel and returns immediately. All HTTP work happens in a background task. - Handlebars templates β register named templates, render any
serde::Serializetype into them. - TypeId-based matcher β register a rule closure per type,
sendresolves the right template automatically. - Runtime template mutations β add, update, or remove templates while the dispatcher is running.
- Fan-out to multiple destinations β one send reaches Discord, Telegram, Slack, Ntfy, and any other platform concurrently.
- Platform hints β pass per-message metadata like embed color or title via
WithHintswithout polluting your data types. - Graceful shutdown β
shutdown().awaitdrains the queue before returning. - Flush barrier β
flush().awaitwaits for all queued jobs to complete without closing the channel. - Isolated errors β a failing destination never affects others. Errors are reported via an
on_errorcallback.
| Platform | Type | Notes |
|---|---|---|
| Discord | β | Webhook URL, embed color, embed title, username |
| Telegram | β | Bot token + chat ID, MarkdownV2 / HTML / Plain, silent, disable preview |
| Slack | β | Incoming webhook URL, username, icon emoji |
| Ntfy | β | Self-hosted or ntfy.sh, title, priority, tags |
| Generic HTTP | β | Plain JSON POST to any URL, configurable body key |
| Platform | Notes |
|---|---|
| Microsoft Teams | Adaptive Cards, high demand in finance orgs |
| Mattermost | Slack-compatible payload |
| Pushover | Mobile push, popular with individual traders |
| PagerDuty | On-call alerting, severity levels map well to trade events |
| OpsGenie | Popular PagerDuty alternative |
| Lark / Feishu | Large user base in Asian markets |
| DingTalk | Same target market as Lark |
| WeChat Work | Enterprise WeChat, relevant for CN trading firms |
| Email (SMTP) | Via lettre, useful for low-frequency critical alerts |
[dependencies]
chipa-webhooks = "0.1"use chipa_webhooks::{Destination, Discord, WebhookDispatcher, WithHints};
use serde::Serialize;
#[derive(Serialize)]
struct TradeSignal {
asset: String,
action: String,
entry_price: f64,
}
#[tokio::main]
async fn main() {
let mut dispatcher = WebhookDispatcher::builder()
.template("signal_buy", "π **BUY** β {{asset}} @ {{entry_price}}")
.template("signal_sell", "π **SELL** β {{asset}} @ {{entry_price}}")
.template("default", "βͺ {{action}} β {{asset}}")
.destination(Destination::new(
"discord",
Discord::new("https://discord.com/api/webhooks/...").with_username("Chipa"),
))
.on_error(|e| eprintln!("webhook error: {e}"))
.build()
.expect("failed to build dispatcher");
dispatcher.register_rule(|e: &TradeSignal| match e.action.as_str() {
"Buy" | "StrongBuy" => "signal_buy",
"Sell" | "StrongSell" => "signal_sell",
_ => "default",
});
let signal = TradeSignal {
asset: "BTCUSDT".into(),
action: "Buy".into(),
entry_price: 67_420.50,
};
// Matcher resolves "signal_buy" automatically
dispatcher.send(&signal).await.unwrap();
// Explicit template + platform hints
dispatcher
.send_with_hints(
&signal,
WithHints::new()
.d_color(0x2ecc71)
.d_title("π BUY β BTCUSDT"),
)
.await
.unwrap();
dispatcher.shutdown().await;
}| Method | Template source | Hints |
|---|---|---|
send(&event) |
Matcher (TypeId lookup) | β |
send_with_hints(&event, hints) |
Matcher (TypeId lookup) | β |
send_with_template("name", &event) |
Explicit | β |
send_with_template_and_hints("name", &event, hints) |
Explicit | β |
All four methods are async and return immediately after queuing β the HTTP request happens in the background.
WithHints is a builder that attaches per-message metadata. Hints are stripped from the template data before rendering so they never appear in the message text.
WithHints::new()
// Discord
.d_color(0xe74c3c) // embed color (u32 RGB)
.d_title("π SELL signal") // embed title
// Telegram
.tg_silent() // disable notification sound
.tg_disable_preview() // disable link preview
// Slack
.slack_username("ChipaBot") // override bot name for this message
.slack_emoji(":chart:") // override icon emoji for this message
// Ntfy
.ntfy_title("Trade Alert") // notification title
.ntfy_priority(4) // 1 (min) to 5 (max)
.ntfy_tags("trading,btc") // comma-separated, additive with struct-level tags| Meaning | Hex |
|---|---|
| Buy / Win / OK | 0x2ecc71 |
| Sell / Loss / Error | 0xe74c3c |
| Hold / Neutral | 0x95a5a6 |
| Warning | 0xe67e22 |
| Info | 0x3498db |
One dispatcher fans out to all destinations concurrently. Each destination is fully isolated β a timeout or error on one never delays or affects the others.
use chipa_webhooks::{Destination, Discord, Generic, Ntfy, Slack, Telegram, WebhookDispatcher, WithHints};
use serde::Serialize;
#[derive(Serialize)]
struct Signal {
asset: String,
action: String,
entry_price: f64,
}
#[tokio::main]
async fn main() {
let dispatcher = WebhookDispatcher::builder()
.template("signal", "**{{action}}** β {{asset}} @ {{entry_price}}")
.destination(Destination::new(
"discord",
Discord::new("https://discord.com/api/webhooks/...").with_username("Chipa"),
))
.destination(Destination::new(
"telegram",
Telegram::new("bot_token", -1001234567890),
))
.destination(Destination::new(
"slack",
Slack::new("https://hooks.slack.com/services/...").with_icon_emoji(":chart_with_upwards_trend:"),
))
.destination(Destination::new(
"ntfy",
Ntfy::new("https://ntfy.sh", "trading-alerts")
.with_priority(4)
.with_tags(["trading", "signal"]),
))
.destination(Destination::new(
"webhook-site",
Generic::new("https://webhook.site/your-uuid").with_body_key("content"),
))
.on_error(|e| eprintln!("webhook error [{e}]"))
.build()
.expect("failed to build dispatcher");
dispatcher
.send_with_template_and_hints(
"signal",
&Signal {
asset: "BTCUSDT".into(),
action: "Buy".into(),
entry_price: 67_420.50,
},
WithHints::new()
.d_color(0x2ecc71)
.d_title("π BUY β BTCUSDT")
.ntfy_priority(5)
.slack_emoji(":rocket:"),
)
.await
.unwrap();
dispatcher.shutdown().await;
}Templates can be changed while the dispatcher is running. Always call flush().await before mutating to ensure all queued sends complete first β sends are fire-and-forget into a channel, so without a flush, in-flight jobs may render against the mutated template.
// Phase 1 β send with original templates
dispatcher.send_with_template("report", &event).await.unwrap();
// Wait for all queued HTTP requests to finish before mutating
dispatcher.flush().await.unwrap();
// Phase 2 β mutate, then send
dispatcher.update_template("report", "π NEW FORMAT β {{asset}}: {{value}}").unwrap();
dispatcher.register_template("alert", "π¨ ALERT β {{message}}").unwrap();
dispatcher.remove_template("old");
dispatcher.send_with_template("report", &event).await.unwrap();
dispatcher.send_with_template("alert", &other).await.unwrap();// Closes the channel, drains every queued job to completion, then returns.
// No messages are lost.
dispatcher.shutdown().await;Implement the Platform trait to add any HTTP-based platform:
use chipa_webhooks::platform::Platform;
use serde_json::{Map, Value, json};
struct MyPlatform {
url: String,
}
impl Platform for MyPlatform {
fn build_payload(&self, rendered: &str, hints: &Map<String, Value>) -> Value {
json!({
"text": rendered,
"priority": hints.get("__ntfy_priority").cloned().unwrap_or(Value::Null),
})
}
fn endpoint(&self) -> &str {
&self.url
}
}
// Use it like any built-in platform
Destination::new("my-platform", MyPlatform { url: "https://...".into() });- Create a bot via @BotFather and copy the token.
- Start a conversation with the bot (send
/start). - Retrieve your chat ID:
Look for
https://api.telegram.org/bot<TOKEN>/getUpdates"chat": { "id": 123456789 }in the response.
use chipa_webhooks::{Destination, Telegram, ParseMode};
Destination::new(
"telegram",
Telegram::new("7123456789:AAFxxx...", -1001234567890)
.with_parse_mode(ParseMode::Html),
)Works with the public ntfy.sh server or any self-hosted instance.
use chipa_webhooks::{Destination, Ntfy};
// Public ntfy.sh
Destination::new(
"ntfy",
Ntfy::new("https://ntfy.sh", "my-trading-alerts")
.with_title("Trade Alert")
.with_priority(4)
.with_tags(["trading", "signal", "btc"]),
)
// Self-hosted
Destination::new(
"ntfy-self",
Ntfy::new("https://ntfy.myserver.com", "alerts"),
)Caller
β
β send(&event) β async, returns after channel push
βΌ
kanal bounded channel (capacity: 1024 by default)
β
βΌ
Background task (tokio::spawn)
βββ TemplateEngine::render() β handlebars, Arc<RwLock> shared with caller
βββ fan-out via join_all
βββ Platform::build_payload() + reqwest POST β Discord
βββ Platform::build_payload() + reqwest POST β Telegram
βββ Platform::build_payload() + reqwest POST β Slack
βββ Platform::build_payload() + reqwest POST β Ntfy
βββ Platform::build_payload() + reqwest POST β ...
- The
TemplateEngineis shared between the caller and the background task viaArc<RwLock>. The caller holds a write lock only duringregister/update/removecalls. The background task holds a read lock only duringrender. - A
Flushsentinel job is enqueued byflush(). When the background task reaches it, all prior jobs are guaranteed to have completed their HTTP fan-outs.
DISCORD_WEBHOOK=https://discord.com/api/webhooks/<id>/<token>
WEBHOOK_SITE_URL=https://webhook.site/<uuid>| Crate | Role |
|---|---|
kanal |
High-performance async channel |
tokio |
Async runtime |
reqwest |
HTTP client (rustls, no OpenSSL) |
handlebars |
Template engine |
serde + serde_json |
Serialization |
futures |
join_all for concurrent fan-out |
thiserror |
Error type derivation |
tracing |
Structured logging |