Webhooks
Every Swing Deck alert can be POSTed to a URL you control — signed with HMAC-SHA256 so you know it really came from your dashboard. Wire it into Discord, Slack, n8n, Zapier, or a custom bot.
Quick start
- Open the dashboard →
⚙icon → 🔔 Alerts tab - Scroll to Outbound Webhooks, paste your URL, click Add webhook
- Copy the 64-char HMAC secret that appears — it's shown once, never again
- Configure your endpoint to verify the signature (examples below)
- Click Send test alert to verify the round trip
Payload shape
Every webhook delivery is a POST with Content-Type: application/json. Body:
{
"event": "stop_loss_breach",
"severity": "critical",
"ticker": "NVDA",
"message": "NVDA breached its stop-loss. Price $180.00 vs stop $200.00 (-18.2% from entry).",
"details": {
"ticker": "NVDA",
"price": 180.00,
"stop": 200.00,
"shares": 100,
"entry": 220.00,
"pct_from_entry": -18.18,
"severity": "critical"
},
"timestamp": "2026-04-21T14:32:05.123456+00:00"
}
Event types
| event | details | Severity range |
|---|---|---|
stop_loss_breach | ticker, price, stop, shares, entry, pct_from_entry | critical / warning |
score_drop | previous_score, current_score, drop, grade, price | warning |
vix_regime_change | previous_regime, current_regime, vix | warning / critical |
regime_change | previous_regime, current_regime, subtitle | warning / critical |
earnings_warning | earnings_days, earnings_date, price | warning |
pillar13_decay | hours_to_event, penalty, ceasefire | warning |
daily_digest | regime, vix, positions, top_tickers | info |
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SwingDeck-Webhook/1.0 |
X-SwingDeck-Event | Event type (matches body.event) |
X-SwingDeck-Signature | sha256=<hex> — HMAC of the raw body bytes |
X-SwingDeck-Delivery | 16-char hex — idempotency key (dedupe on this) |
X-SwingDeck-Timestamp | Unix seconds at dispatch time (reject old deliveries if you need replay protection) |
Signature verification
timingSafeEqual / hmac.compare_digest) to avoid timing attacks.
Python (FastAPI / Flask)
import hmac, hashlib
from fastapi import Request, HTTPException
SECRET = "your-64-char-hex-secret-from-swing-deck".encode()
async def verify(request: Request):
raw = await request.body()
provided = request.headers.get("X-SwingDeck-Signature", "")
if not provided.startswith("sha256="):
raise HTTPException(status_code=401, detail="missing signature")
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, provided):
raise HTTPException(status_code=401, detail="bad signature")
# body is now trusted
return await request.json()
Node.js (Express)
const crypto = require('crypto');
app.post('/swingdeck', express.raw({ type: 'application/json' }), (req, res) => {
const SECRET = process.env.SWINGDECK_SECRET;
const provided = req.headers['x-swingdeck-signature'] || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(req.body)
.digest('hex');
if (
expected.length !== provided.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
) {
return res.status(401).send('bad signature');
}
const alert = JSON.parse(req.body.toString());
// alert.event, alert.ticker, alert.message, alert.details — trusted
res.status(200).end();
});
Go (net/http)
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
)
func handle(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
provided := r.Header.Get("X-SwingDeck-Signature")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(provided)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// body is trusted — parse and handle
w.WriteHeader(http.StatusOK)
}
Recipes
Discord — direct webhook
Discord's "incoming webhooks" expect a different JSON shape than Swing Deck sends, so you need a tiny adapter. Use webhook.site or a Cloudflare Worker to transform:
// Cloudflare Worker — transforms Swing Deck alert → Discord embed
export default {
async fetch(req, env) {
const raw = await req.text();
// TODO: verify HMAC as shown above (skipped for brevity)
const a = JSON.parse(raw);
const color = a.severity === 'critical' ? 0xff3d5c :
a.severity === 'warning' ? 0xf0960a : 0x00bcd4;
return fetch(env.DISCORD_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: `⚠ ${a.event.replace(/_/g,' ').toUpperCase()}${a.ticker ? ' — ' + a.ticker : ''}`,
description: a.message,
color,
timestamp: a.timestamp,
}],
}),
});
}
};
Slack — incoming webhook
Same approach — adapter translates into Slack's blocks format:
body: JSON.stringify({
text: `Swing Deck: ${a.event} — ${a.ticker || ''}`,
blocks: [
{ type: 'header', text: { type: 'plain_text',
text: `${a.event.toUpperCase()} — ${a.ticker || '—'}` }},
{ type: 'section', text: { type: 'mrkdwn', text: a.message }},
]
})
Zapier / n8n — no-code
Both platforms have built-in "Webhook" triggers that accept any JSON body. Paste your Swing Deck endpoint URL from the integration trigger, then filter/branch on event, severity, or ticker in the next step.
Retry & delivery guarantees
Retry policy
On any network failure or 5xx response, Swing Deck retries:
| Attempt | Delay since first send |
|---|---|
| 1 | 0s (immediate) |
| 2 | +2s |
| 3 | +8s |
| 4 | +32s |
On a 4xx response (except 408 and 429), retries abort immediately — those indicate your endpoint rejected the payload and won't accept it on retry.
Idempotency
Use X-SwingDeck-Delivery as a dedup key. If your endpoint sees the same delivery ID twice, ignore the second one — it means a prior attempt succeeded but we didn't see the 2xx response.
Ordering
Not guaranteed. If alert A fires at t=0 and alert B fires at t=1, alert B may arrive first if A's first attempt fails. Timestamp-sort in your handler if ordering matters.
Backlog
None. If all 4 attempts fail, the alert is logged locally (webhooks_send_log.json) and dropped. Swing Deck does NOT queue undelivered webhooks — that would leak user data to disk indefinitely. If your endpoint is down, you miss that alert.