Push events

Outbound Webhooks

Subscribe to workspace events and Saltare will POST signed JSON to any public URL you provide. Requires the Business plan.

Setup

  1. Open Settings → Webhooks in your workspace.
  2. Enter a name, delivery URL, and (recommended) a signing secret — any string of your choice.
  3. Pick the event types you want to receive.
  4. Hit Test to fire a synthetic webhook.test event and verify your receiver is reachable.

Event types

EventFires when
task.createdA task is created (UI, API, MCP, or agent)
task.completedA task transitions to completed
message.postedA user or agent posts a non-system message
document.publishedA document's published flag flips to true
webhook.testYou click Test in settings

Envelope

Every delivery has the same shape:

{
  "event": "task.created",
  "delivery_id": "6f8a2c9e-1f2e-4a74-b0d5-3e6a1b8c9f02",
  "created_at": "2026-04-15T12:34:56Z",
  "workspace_id": 42,
  "data": {
    "id": 1234,
    "slug": "ship-beta-a3f2c1",
    "title": "Ship the beta",
    "state": "open",
    "priority": "high",
    "project_id": 7
  }
}

Headers

Content-Type: application/json
User-Agent: Saltare-Webhooks/1.0
X-Saltare-Event: task.created
X-Saltare-Delivery: 6f8a2c9e-1f2e-4a74-b0d5-3e6a1b8c9f02
X-Saltare-Timestamp: 1745750096
X-Saltare-Signature: t=1745750096,v1=<64 hex chars>

X-Saltare-Delivery is unique per delivery — use it for idempotency if your receiver might see duplicates from retries.

Signature

The signature is Stripe-style. The payload signed is the exact raw request body prefixed with the timestamp and a period:

signed_payload = "#{timestamp}.#{raw_body}"
signature      = HMAC-SHA256(secret, signed_payload)
header         = "t=#{timestamp},v1=#{hex(signature)}"

Compare with a constant-time equality check. Reject deliveries whose timestamp is more than a few minutes off from your clock.

Verifier snippets

Ruby

require "openssl"

def verify(secret, raw_body, header, tolerance: 300)
  parts = header.to_s.split(",").to_h { |p| p.split("=", 2) }
  ts, sig = parts["t"].to_i, parts["v1"].to_s
  return false if ts.zero? || sig.empty?
  return false if (Time.now.to_i - ts).abs > tolerance

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw_body}")
  Rack::Utils.secure_compare(expected, sig)
end

Node.js

const crypto = require("crypto");

function verify(secret, rawBody, header, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(",").map(p => p.split("=", 2))
  );
  const ts = parseInt(parts.t, 10);
  const sig = parts.v1 || "";
  if (!ts || !sig) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSec) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python

import hmac, hashlib, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    try:
        ts = int(parts["t"])
        sig = parts["v1"]
    except (KeyError, ValueError):
        return False

    if abs(int(time.time()) - ts) > tolerance:
        return False

    signed = f"{ts}.{raw_body.decode()}".encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

Always verify against the raw request body — don't reparse and re-serialize the JSON, since whitespace or key ordering differences will break the signature.

Retries and backoff

Any non-2xx response, timeout, or connection error triggers a retry. The retry cadence is polynomial backoff (ActiveJob's polynomially_longer), up to 8 attempts. The 9th failure marks the delivery as dead and increments the subscription's failure counter.

Twenty consecutive failures across deliveries auto-pauses the subscription for an hour — preventing a broken receiver from consuming delivery-job capacity indefinitely. You'll see it marked Paused in the settings UI; fix your receiver, then unpause by saving the subscription.

Receivers must respond within 10 seconds. Timeouts count as failures.

SSRF protection

Saltare validates the delivery URL both at subscribe time and on every attempt. URLs resolving to RFC 1918, loopback, cloud metadata, or other private ranges are rejected — a delivery to a now-internal host is marked dead rather than sent. You can subscribe using a domain; the DNS lookup happens per-attempt.

Idempotency

Retries will send the same delivery_id. Log the id and skip payloads you've already processed. A receiver that returns 200 for a duplicate delivery is the simplest correct design.