Outbound Webhooks
Subscribe to workspace events and Saltare will POST signed JSON to any public URL you provide. Requires the Business plan.
Setup
- Open Settings → Webhooks in your workspace.
- Enter a name, delivery URL, and (recommended) a signing secret — any string of your choice.
- Pick the event types you want to receive.
- Hit Test to fire a synthetic
webhook.testevent and verify your receiver is reachable.
Event types
| Event | Fires when |
|---|---|
| task.created | A task is created (UI, API, MCP, or agent) |
| task.completed | A task transitions to completed |
| message.posted | A user or agent posts a non-system message |
| document.published | A document's published flag flips to true |
| webhook.test | You 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.