Reference
Alert channels & webhooks
Every fired canary sends an alert to whichever channels are actually configured. Email needs a working sender and Telegram needs a linked chat; an unconfigured built-in channel is skipped rather than queued, so if neither is set up the alert reaches nobody. On top of those you can fan the same alert out to Slack, Discord, or any HTTPS endpoint via an HMAC-signed webhook. Channels are configured per account under Settings → Alert channels, or through the /api/alert-channels endpoints.
Delivery semantics for all three kinds: 10 second timeout per attempt, up to 3 attempts with exponential backoff, and every delivery outcome is recorded in your account audit trail. Failures are surfaced per channel as lastError.
Slack setup
- In Slack, open your workspace's app directory and add (or open) the Incoming Webhooks app.
- Create a new webhook, pick the channel alerts should land in (for example
#security-alerts), and copy the generated URL, which starts withhttps://hooks.slack.com/services/…. - In CanaryVaults, go to
Settings → Alert channels → Add channel → Slack, paste the URL, and save. - Press Test. A synthetic alert (clearly marked, no canary involved) should appear in the channel.
Alerts render as Slack blocks: a headline with the alert level and platform, followed by attacker IP, location, attacker type, and the risk score. When a decoy email address fired, the mail server that delivered the email takes the attacker's place and is named as one.
Discord setup
- In Discord, open the target channel's settings:
Edit channel → Integrations → Webhooks → New webhook. - Name it (for example
CanaryVaults), then Copy webhook URL. It looks likehttps://discord.com/api/webhooks/<id>/<token>. - In CanaryVaults, add a channel of kind Discord and paste the URL.
- Press Test to verify delivery. Alerts arrive as an embed, color-coded by alert level.
Generic webhook setup
- Stand up an HTTPS endpoint that accepts
POST application/json. Private and loopback addresses are rejected: the endpoint must be publicly reachable. - Generate a shared secret (at least 16 characters, e.g.
openssl rand -hex 24) and store it in your receiver's secret manager. - Add a channel of kind Webhook with the URL and the secret. CanaryVaults signs every delivery with it.
- Verify the signature on every request before trusting the payload (snippets below), then press Test.
Each delivery carries these headers:
Content-Type: application/json
User-Agent: CanaryVaults-Webhook/1.0
X-CanaryVaults-Event: canary.tripwire_triggered
X-CanaryVaults-Timestamp: 1784887923 # unix seconds at send time
X-CanaryVaults-Signature: sha256=8f2a... # HMAC-SHA256(secret, "{timestamp}.{raw_body}")Payload schema
The body is stable JSON (schema version 1). Test alerts use event: "canary.test_alert" and set data.test: true. Never treat them as incidents.
{
"version": "1",
"event": "canary.tripwire_triggered", // or "canary.test_alert"
"sentAt": "2026-07-19T10:12:03Z",
"data": {
"event": "canary.tripwire_triggered",
"alertLevel": "CRITICAL", // CRITICAL | HIGH | MEDIUM
"platform": "gmail", // where the canary credential was used
"canaryId": "canary_abc123",
"canaryAgeDays": 4,
"attacker": {
"ip": "203.0.113.10", // "" when there is no caller's address (see below)
"country": "NL",
"city": "Amsterdam",
"org": "Example Hosting BV",
"type": "VPN_PROXY_USER",
"abuseScore": 67 // 0-100 (AbuseIPDB confidence), or null
},
"mailServer": { // only when a decoy email address fired
"ip": "198.51.100.25", // the server that delivered the email
"country": "US",
"city": "Mountain View",
"org": "Example Mail Provider"
},
"riskScore": 72, // 0-100 composite risk
"riskLevel": "HIGH",
"interpretation": "ACTIVE PIPELINE: Credential stolen within 1 week.",
"threatEventId": "5f1c...", // null for test alerts
"triggeredAt": "2026-07-19T10:12:01Z",
"test": true // only present on test alerts
}
}When the canary that fired is a decoy email address, data.attacker.ip is empty and data.mailServer names the server that delivered the email. That server belongs to the sender's mail provider, not to the sender, so do not block it.
data.attacker.ip is empty too when there is no caller to name: a CanaryAgent alert about your own agent (one gone silent, a skill it blocked) and a decoy tool server's test alert. data.attacker.abuseScore is null when no address was scored.
Verifying signatures
Recompute the HMAC over {timestamp}.{raw_body} using the exact bytes you received (do not re-serialize the JSON), compare with a constant-time comparison, and reject timestamps older than a few minutes to block replays.
import hashlib, hmac, time
def verify(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
# A missing or non-numeric header is hostile input, not a bug: return
# False rather than raising out of your handler.
try:
sent_at = int(timestamp)
except (TypeError, ValueError):
return False
# Reject stale deliveries (replay protection)
if abs(time.time() - sent_at) > 300:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
# In your handler:
# verify(SECRET,
# request.headers["X-CanaryVaults-Timestamp"],
# request.raw_body, # the exact bytes, NOT re-serialized JSON
# request.headers["X-CanaryVaults-Signature"])const crypto = require("crypto");
function verify(secret, timestamp, rawBody, signature) {
// Number(undefined) is NaN and every NaN comparison is false, so a missing
// header would have slipped past the replay window unchecked.
const sentAt = Number(timestamp);
if (!Number.isFinite(sentAt)) return false;
if (Math.abs(Date.now() / 1000 - sentAt) > 300) return false;
const signed = Buffer.concat([Buffer.from(timestamp + "."), rawBody]);
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
// timingSafeEqual throws RangeError unless both buffers are the same
// length -- and a missing or truncated signature is exactly the hostile
// case this function exists to reject. Compare lengths first.
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}Retries, failures, and testing
A delivery attempt counts as successful on a 2xx response and nothing else. Redirects are not followed, so a 3xx means the configured URL is wrong: it is reported as a failure and not retried. Network errors and 5xx are retried up to 3 total attempts with exponential backoff; 4xx responses other than 408/409/425/429 are not retried. After a failed delivery the channel shows lastError in Settings and the failure is written to your audit trail. Disabled channels are skipped entirely.
# fire a synthetic alert through a configured channel curl -X POST https://api.canaryvaults.com/api/alert-channels/<channel-id>/test \ -H "Authorization: Bearer $CV_TOKEN"
A test is sent to the channel the way an alert is, with the same timeout and retries. Tests take turns, so that they never hold up real alerts: when too many are being sent at the same time, a test waits up to 10 seconds for its turn, and past that it is answered with a 429 and nothing is sent. Try again in a minute.