Skip to content

Model & govern / Drift, notifications and webhooks

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 main or 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.detected is notified (Warning when breaking).
  • 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 ​

EventDefault recipients
Scan completed / failed, change request merged, drift detectedOwner, Admin, Steward, Modeler
Release publishedEveryone
Data-quality incident, certification requested or expiringOwner, Admin, Steward
Source failingOwner, Admin
Audit chain tamperingOwners 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-secret replaces 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 ​

  1. Read the raw body and X-Keel-Signature: t=<unix seconds>,v1=<hex>.
  2. Reject when |now − t| > 300 seconds (replay window).
  3. Compute hex(HMAC-SHA256(key = UTF-8 signing secret, message = "{t}.{body}")) and compare it in constant time with each v1 value (there can be several during a secret rotation).
  4. Drop duplicates by X-Keel-Event-Id (it equals the body's id; retries keep it and are re-signed with a new t).
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.

Anchor Keel is a working product name: one platform, a shared core and licensed modules (Keel Semantic, Keel Migrate). Documentation built from the repository — facts are derived from code, ADRs and the decisions log.