Appearance
Drift, notifications and webhooks
Schema drift
Sources change. Keel runs a scheduled drift scan per source — a metadata-only discovery run (Harvest → Drift → Relationships → Classify, no profiling) — and compares it with the last snapshot.
- Schedule: five-field UTC cron per source, default
0 3 * * *(PUT …/sources/{id}/drift-schedule, or off). Only sources scanned at least once are checked. Check for drift now:POST …/sources/{id}:check-drift. - Detected: tables added, removed or renamed (column-name similarity ≥ 0.8); columns added, removed or renamed (same position and type with a similar name —
cust_nm→customer_name— or the only removed/added pair of a type family); type changes; primary, unique and foreign key changes; view definition changes. - Breaking when a model object on
mainor in the latest release reads what changed. Additions never break; type changes break only across type families. - What happens:
- Renames are fixed mechanically on
keel/drift-{id}by Keel, with a change request for a person to review. - Other breaking changes (removals, type changes) stay open drift events listed in that change request.
- Additions re-run inference, so new tables and columns reach the Review queue.
drift.detectedis notified (Warning when breaking).
- Renames are fixed mechanically on
- Drift events are triaged on Sources › {source} › Schema drift: Open → Acknowledged → Resolved.
INFO
Drift events don't resolve automatically when their fix is merged yet — resolve them by hand.
Notifications
| Event | Default recipients |
|---|---|
| Scan completed / failed, change request merged, drift detected | Owner, Admin, Steward, Modeler |
| Release published | Everyone |
| Data-quality incident, certification requested or expiring | Owner, Admin, Steward |
| Source failing | Owner, Admin |
| Audit chain tampering | Owners only (not configurable) |
Channels per person: in-app inbox (the bell), email, and a daily digest at 07:00 UTC (Critical emails are immediate). Set them in Settings › Notifications. Each person gets an event at most once per hour.
Slack, Teams and webhooks
Workspace admins add channels in Settings › Integrations (or POST …/notification-channels) with a name, kind (Slack, Teams, Webhook), URL and the event types it receives. Tenant-wide notifications (audit tampering) never go to channels.
- The whole URL is treated as a secret — stored in the secret store, write-only, only the host is ever shown.
- https only; private, loopback, link-local and metadata hosts are refused when saved and re-checked at connect time; redirects are not followed; 10 s timeout.
- Delivery: 5 attempts with backoff, then dead-lettered. A 4xx (except 408, 425, 429) or a redirect fails at once. Each channel has a delivery log (status codes and Keel errors only). Send test queues a test message.
- A webhook's signing secret is shown once when the channel is created;
:rotate-secretreplaces it.
Webhook payload
http
POST /your/receiver
Content-Type: application/json
X-Keel-Signature: t=1790000000,v1=5d41402abc4b2a76b9719d911017c592…
X-Keel-Event-Id: 0199a0b1-…
X-Keel-Event-Type: quality.incident
{ "id": "0199a0b1-…", "type": "quality.incident", "severity": "Critical", "title": "…", "body": "…",
"link": "https://…", "workspaceId": "…", "occurredAt": "2026-09-29T12:00:00Z" }Payloads are metadata only — never data values.
Verifying signatures
- Read the raw body and
X-Keel-Signature: t=<unix seconds>,v1=<hex>. - Reject when
|now − t| > 300seconds (replay window). - Compute
hex(HMAC-SHA256(key = UTF-8 signing secret, message = "{t}.{body}"))and compare it in constant time with eachv1value (there can be several during a secret rotation). - Drop duplicates by
X-Keel-Event-Id(it equals the body'sid; retries keep it and are re-signed with a newt).
js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyKeelWebhook(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = header.split(',').map((p) => p.trim().split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
if (!Number.isFinite(t) || Math.abs(nowSeconds - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest();
return parts
.filter(([k]) => k === 'v1')
.some(([, hex]) => {
const given = Buffer.from(hex, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}python
import hashlib, hmac, time
def verify_keel_webhook(raw_body: bytes, header: str, secret: str, now: int | None = None) -> bool:
parts = [p.strip().split("=", 1) for p in header.split(",")]
try:
t = int(next(v for k, v in parts if k == "t"))
except (StopIteration, ValueError):
return False
if abs((now or int(time.time())) - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")csharp
// Keel.Identity.Contracts.WebhookSignature.Verify implements the same recipe.
using System.Security.Cryptography;
using System.Text;
static bool VerifyKeelWebhook(string rawBody, string header, string secret, DateTimeOffset now)
{
var parts = header.Split(',').Select(p => p.Trim().Split('=', 2)).Where(p => p.Length == 2).ToList();
if (!long.TryParse(parts.FirstOrDefault(p => p[0] == "t")?[1], out var t) || Math.Abs(now.ToUnixTimeSeconds() - t) > 300)
return false;
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
return parts.Where(p => p[0] == "v1").Any(p =>
p[1].Length == expected.Length * 2 && CryptographicOperations.FixedTimeEquals(Convert.FromHexString(p[1]), expected));
}Then store X-Keel-Event-Id and ignore any id you have already processed.