Patterns & Recipes

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

6 min read · Intermediate

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

  1. Go to Settings → Webhooks in your workspace
  2. Click New Subscription
  3. 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
  4. Click Create
  5. Click Send Test to fire a webhook.test event

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.published and message.posted to 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.