React to Events with Webhooks
Build a webhook receiver that verifies Saltare signatures and reacts to workspace events — with examples in Node.js and Python
Instead of polling the API, you can have Saltare push events to your server the moment they happen. This tutorial walks through setting up an outbound webhook subscription, building a receiver that verifies HMAC signatures, and handling real events — with complete examples in Node.js and Python.
For the event catalog and envelope format, see the Webhooks docs.
The flow
Saltare event ──HTTPS POST──▶ Your server
│
Verify signature
│
Handle event
│
Return 200
Every delivery is signed with HMAC-SHA256 so you can verify it came from Saltare and hasn't been tampered with.
Prerequisites
- A Saltare workspace on the Business plan (outbound webhooks are plan-gated)
- A publicly reachable HTTPS endpoint (or a tool like ngrok for local development)
Step 1: Set up a tunnel for local development
If you're developing locally, expose your server with ngrok:
# Install ngrok (macOS)
brew install ngrok
# Start a tunnel on port 3000
ngrok http 3000
Copy the https://…ngrok-free.app URL — you'll use this as your webhook delivery URL.
Step 2: Create a webhook subscription
- Go to Settings → Webhooks in your workspace
- Click New Subscription
- Fill in:
- Name:
dev-receiver - URL:
https://your-ngrok-url.ngrok-free.app/webhooks/saltare - Secret: pick a strong random string (e.g.
whsec_k9x2mF7vQpL8nR3j) - Events: check
task.created,task.completed,message.posted
- Name:
- Click Create
- Click Send Test to fire a
webhook.testevent
You should see a POST hit your ngrok dashboard. If it returns a connection error, your local server isn't running yet — that's fine, we'll build it next.
Step 3: Build the receiver (Node.js)
// server.js
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
const MAX_AGE_SECONDS = 300; // reject deliveries older than 5 minutes
// Parse raw body for signature verification
app.use("/webhooks/saltare", express.raw({ type: "application/json" }));
function verifySignature(req) {
const signature = req.headers["x-saltare-signature"];
const timestamp = req.headers["x-saltare-timestamp"];
if (!signature || !timestamp) return false;
// Reject stale deliveries
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (age > MAX_AGE_SECONDS) return false;
// Compute expected signature
const payload = `${timestamp}.${req.body}`;
const expected = crypto
.createHmac("sha256", SECRET)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(`sha256=${expected}`)
);
}
app.post("/webhooks/saltare", (req, res) => {
if (!verifySignature(req)) {
console.error("Signature verification failed");
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
console.log(`Received: ${event.event} (${event.delivery_id})`);
switch (event.event) {
case "task.created":
console.log(`New task: ${event.data.title} (${event.data.slug})`);
break;
case "task.completed":
console.log(`Completed: ${event.data.title}`);
break;
case "message.posted":
console.log(`Message in #${event.data.channel_slug}: ${event.data.body?.slice(0, 80)}`);
break;
case "webhook.test":
console.log("Test event received — webhook is working!");
break;
default:
console.log(`Unhandled event type: ${event.event}`);
}
// Always return 200 quickly — do heavy work async
res.status(200).json({ received: true });
});
app.listen(3000, () => console.log("Webhook receiver listening on :3000"));
Run it:
export WEBHOOK_SECRET="whsec_k9x2mF7vQpL8nR3j"
node server.js
Now click Send Test again in Saltare's webhook settings. You should see Test event received in your terminal.
Step 4: Build the receiver (Python)
# server.py
"""Saltare webhook receiver with HMAC-SHA256 verification."""
import hashlib
import hmac
import os
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"]
MAX_AGE_SECONDS = 300
def verify_signature(req):
"""Verify the HMAC-SHA256 signature on the delivery."""
signature = req.headers.get("X-Saltare-Signature", "")
timestamp = req.headers.get("X-Saltare-Timestamp", "")
if not signature or not timestamp:
return False
# Reject stale deliveries
try:
age = abs(time.time() - int(timestamp))
if age > MAX_AGE_SECONDS:
return False
except ValueError:
return False
# Compute expected signature
payload = f"{timestamp}.{req.get_data(as_text=True)}"
expected = hmac.new(
SECRET.encode(), payload.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, f"sha256={expected}")
@app.route("/webhooks/saltare", methods=["POST"])
def handle_webhook():
if not verify_signature(request):
return jsonify({"error": "Invalid signature"}), 401
event = request.get_json()
event_type = event.get("event")
print(f"Received: {event_type} ({event.get('delivery_id')})")
if event_type == "task.created":
data = event["data"]
print(f" New task: {data['title']} ({data['slug']})")
elif event_type == "task.completed":
data = event["data"]
print(f" Completed: {data['title']}")
elif event_type == "message.posted":
data = event["data"]
print(f" Message in #{data['channel_slug']}: {data.get('body', '')[:80]}")
elif event_type == "webhook.test":
print(" Test event received — webhook is working!")
# Return 200 quickly
return jsonify({"received": True})
if __name__ == "__main__":
app.run(port=3000)
Run it:
pip install flask
WEBHOOK_SECRET="whsec_k9x2mF7vQpL8nR3j" python server.py
Step 5: Handle real events
Create a task in your workspace. You should see the task.created event arrive at your receiver within a second or two. Complete the task and you'll see task.completed.
Each event delivery includes a delivery_id (UUID) for idempotency. If your server processes the same delivery_id twice, the second one is a retry — you can safely skip it:
const processed = new Set();
app.post("/webhooks/saltare", (req, res) => {
// ... signature verification ...
const event = JSON.parse(req.body);
if (processed.has(event.delivery_id)) {
return res.status(200).json({ received: true, duplicate: true });
}
processed.add(event.delivery_id);
// ... handle event ...
});
In production, use a database or Redis set instead of an in-memory Set.
Retry behavior
If your server returns a non-2xx response or times out, Saltare retries with polynomial backoff up to 8 attempts. After 20 consecutive failures across all deliveries, the subscription auto-pauses — you'll need to re-enable it in Settings → Webhooks.
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | ~1 minute |
| 3 | ~4 minutes |
| 4 | ~15 minutes |
| 5 | ~1 hour |
| 6–8 | Increasing |
Every attempt is logged in the delivery log, visible under your subscription in settings. If a delivery fails, check the log to see the HTTP status and response body your server returned.
Real-world patterns
Forward to Slack
Post a Slack message whenever a task is completed:
case "task.completed":
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `Task completed: *${event.data.title}*`,
}),
});
break;
Sync to an external database
Insert task events into your own database for reporting:
elif event_type == "task.created":
db.execute(
"INSERT INTO saltare_tasks (slug, title, created_at) VALUES (?, ?, ?)",
(data["slug"], data["title"], event["timestamp"]),
)
Trigger a CI pipeline
Start a build when a document is published:
case "document.published":
await fetch("https://api.github.com/repos/org/repo/dispatches", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
event_type: "saltare_doc_published",
client_payload: { slug: event.data.slug, title: event.data.title },
}),
});
break;
Security checklist
- Always verify signatures. Never trust the payload without checking the HMAC.
- Reject stale timestamps. A 5-minute window is reasonable. This prevents replay attacks.
- Use HTTPS. Saltare blocks non-HTTPS delivery URLs.
- Keep your secret secret. Rotate it if compromised — update the subscription in settings and deploy the new secret to your server.
- Return 200 quickly. Do heavy processing async (queue, background job). Saltare's delivery timeout is short; a slow response triggers retries.
Next steps
- Combine with the REST API — use webhook events as triggers, then call back into the API for more context. See Build a Task Bot with the REST API.
- Add more event types — subscribe to
document.publishedandmessage.postedto build a full activity stream. - Read the full reference — the Webhooks docs cover the envelope format, all event types, and signature verification in Ruby, Node, and Python.