Cloud

Hosted approvals

Free never calls out. Pro and Team link the proxy to https://app.tablebelt.com. The proxy opens outbound HTTPS only. It does not listen for the cloud.

tablebelt link
tablebelt link --api-key "$TABLEBELT_API_KEY" --redact full

That writes the cloud block, stores the API key in a mode 0600 file, registers the proxy, and prints whether hosted approvals are active. tablebelt unlink removes the block.

Plans

Prices are USD. Checkout shows local currency (GBP/EUR) where available.

If the control plane is unreachable, the hold stays pending. It does not auto-approve. Local tablebelt approve still works while cloud.local_approvals is true (the default). Set it to false when every decision must be hosted. The CLI then refuses approve and tells you where to decide.

Redaction

Set at link time with --redact, or cloud.redact in the config. The cloud stores what the proxy sends. It does not ask for more.

ModeStatement field
fullRaw statement text, truncated at 64 KiB. This is the default. Literals you typed into the statement are included.
normalizedParser-normalized text with $1 placeholders instead of constants.
noneNull. The dashboard, Slack, and email show the fingerprint, kinds, and table names.

What a hold sends

The intake body is fixed. Required fields are always present. Optional fields are omitted when they do not apply.

FieldRequiredMeaning
local_idyesLocal hold id, hold_ plus at least four digits.
held_atyesWhen the proxy held the statement.
expires_atyesheld_at plus hold.pending_ttl (default 24h).
categoryyesHold category, for example drop or unscoped_delete.
kindsyesStatement kinds from the classifier.
redactionyesfull, normalized, or none.
fingerprintyes16 hex characters from the parser fingerprint.
tablesyesSchema, name, estimated rows, bytes, and whether the table exists.
clientyesapplication (startup application_name) and user (the proxy login, not the upstream role).
snapshot_planyeswill_snapshot, and when relevant mode, bytes, and reason.
rule_idnoBuilt-in or custom rule that matched.
statementnoText, or null when redaction is none. Truncated at 64 KiB.
statement_countnoHow many statements were in the message. Default 1.
est_rowsnoEstimate across the plan.
cascadenoObjects a CASCADE would also drop.
in_transactionnoWhether the client session is inside a transaction.

Never sent: parameter values, the DSN, row data, or credentials. With redact: full, literals that are part of the statement text do leave the box. That caveat is also on Security.

Slack and email

Slack shows the statement (truncated, or a redacted fingerprint), the tables, the snapshot plan, and Approve / Deny. When will_snapshot is false, Approve is replaced by a link to review in the dashboard. Email links do not record a vote on GET, so a mail scanner cannot spend them. The confirm button POSTs. One token per approver per hold, and it expires with the hold.

Webhooks

Pro and Team, up to five HTTPS endpoints per org. Events: hold.created, hold.decided, hold.executed, hold.failed, restore.completed, restore.failed, webhook.test. Private, loopback, link-local, and metadata addresses are refused. Redirects are not followed.

Headers: Tablebelt-Signature: t=<unix>,v1=<hex>, Tablebelt-Event, and Tablebelt-Delivery. The HMAC-SHA256 key is the webhook secret. The signed bytes are the timestamp, a single ., then the raw body. Check that string. Do not re-encode the JSON first.

verify.go
package tablebelt

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "errors"
    "strings"
)

// header is Tablebelt-Signature: t=<unix>,v1=<hex>.
// Signed bytes are the timestamp, a dot, then the raw body.
func VerifyWebhook(secret, header string, body []byte) error {
    var ts, sig string
    for _, part := range strings.Split(header, ",") {
        k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
        if !ok {
            continue
        }
        switch k {
        case "t":
            ts = v
        case "v1":
            sig = v
        }
    }
    if ts == "" || sig == "" {
        return errors.New("missing signature")
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(ts))
    mac.Write([]byte("."))
    mac.Write(body)
    sum := hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(sum), []byte(sig)) {
        return errors.New("bad signature")
    }
    return nil
}
verify.js
const crypto = require("crypto");

// header is Tablebelt-Signature: t=<unix>,v1=<hex>.
// body is the raw request bytes, not a re-encoded object.
function verifyWebhook(secret, header, body) {
  const parts = {};
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i === -1) continue;
    parts[part.slice(0, i).trim()] = part.slice(i + 1).trim();
  }
  const mac = crypto
    .createHmac("sha256", secret)
    .update(parts.t + "." + body)
    .digest("hex");
  const a = Buffer.from(mac);
  const b = Buffer.from(parts.v1 || "", "utf8");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error("bad signature");
  }
}

module.exports = { verifyWebhook };

Delivery timeout is 10 seconds. Retries are at 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours. The endpoint is disabled after 20 consecutive failures, and the owner is emailed.