Webhooks

Receive real-time HTTP notifications when things happen in your firm — reports sent, PDFs generated, clients updated, and more.

Plan required: Webhooks require a TEAM or ENTERPRISE plan. Register endpoints at Settings → Developer or via the API.

How it works

  1. Register an HTTPS endpoint (Settings → Developer → Register endpoint).
  2. Select which events you want to receive.
  3. Streamline POSTs a signed JSON payload to your URL when a matching event fires.
  4. Verify the signature, process the event, return 2xx.
  5. If your endpoint returns non-2xx, Streamline retries with exponential backoff.

Payload shape

Every webhook payload uses the same envelope:

{
  "id": "evt_01j4k2m3n4p5q6r7",
  "event": "report.sent",
  "created": 1715289600,
  "firmId": "clm_abc123",
  "data": { ... }
}

The created field is a Unix timestamp (seconds). Use it for replay attack prevention — reject events where |now - created| > 300 seconds.

Event catalog

EventWhen it firesKey data fields
report.sentA report was emailed to a client{ reportId, clientId, sentToEmail, sentAt }
report.generatedPDF generation completed successfully{ reportId, downloadUrl }
client.createdA new client was added to the firm{ clientId, displayName, dataSource }
client.updatedA client record was updated{ clientId, displayName, changes: string[] }

Signature verification

Every request includes an X-Streamline-Signature header. Verify it to confirm the payload came from Streamline and was not tampered with.

Header format

X-Streamline-Signature: t=1715289600,v1=abc123def456...

t is the Unix timestamp. v1 is the HMAC-SHA256 hex digest of {t}.{raw_body}, computed with your endpoint's signing secret.

Verification — TypeScript / Node.js

import crypto from 'crypto';

function verifyWebhook(
  rawBody: string,
  signatureHeader: string,
  secret: string
): boolean {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => p.split('='))
  );
  const timestamp = parts['t'];
  const signature = parts['v1'];

  if (!timestamp || !signature) return false;

  // Replay attack window: 5 minutes
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > 300) return false;

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

  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// Usage in an Express handler:
app.post('/webhooks/streamline', (req, res) => {
  const rawBody = req.body.toString('utf8');
  const header = req.headers['x-streamline-signature'] as string;

  if (!verifyWebhook(rawBody, header, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(rawBody);
  console.log('Received:', event.event, event.data);

  res.sendStatus(200);
});

Verification — Python

import hashlib
import hmac
import json
import time

def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = parts.get("t")
    signature = parts.get("v1")

    if not timestamp or not signature:
        return False

    # Replay attack window: 5 minutes
    age = abs(time.time() - int(timestamp))
    if age > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{raw_body.decode()}".encode(),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)


# Usage in Flask:
from flask import Flask, request, abort

app = Flask(__name__)

@app.route("/webhooks/streamline", methods=["POST"])
def handle_webhook():
    raw_body = request.get_data()
    header = request.headers.get("X-Streamline-Signature", "")

    if not verify_webhook(raw_body, header, os.environ["WEBHOOK_SECRET"]):
        abort(401)

    event = json.loads(raw_body)
    print("Received:", event["event"], event["data"])

    return "", 200
Important: Always use a timing-safe comparison (crypto.timingSafeEqual / hmac.compare_digest). String equality (===, ==) is vulnerable to timing attacks that can leak the expected signature.

Security notes

SSRF protection

Webhook URLs must be https:// and must resolve to a public IP address. Streamline rejects endpoints that resolve to private ranges (10.x, 192.168.x, 172.16–31.x), loopback (127.x, localhost), or AWS metadata endpoints (169.254.x). This check runs both at registration time and at delivery time (DNS rebinding protection).

Replay attacks

Each payload includes a created timestamp. Reject payloads where |now - created| > 300 seconds (5 minutes). This prevents an attacker who intercepts a legitimate payload from replaying it later.

Retry schedule

If your endpoint returns a non-2xx status (or times out after 10 seconds), Streamline retries with exponential backoff:

AttemptDelay after previous failure
1 (initial)
21 minute
35 minutes
430 minutes
52 hours
DeadNo further retries — delivery marked dead after the 5th failed attempt

Delivery history (including failure reasons and response bodies) is visible in Settings → Developer → Webhooks → Deliveries, and via the API at GET /api/v1/webhooks/:id/deliveries.

Delivery limits

Each firm can receive up to 1,000 webhook deliveries per day (UTC). Deliveries beyond the cap are skipped and recorded in your delivery history with status rate_limited — they are not sent and not retried. The counter resets at midnight UTC.

Auto-pause on repeated failures

If an endpoint fails 100 consecutive delivery attempts, Streamline automatically pauses it so a dead URL doesn't consume your delivery quota. A paused endpoint shows as inactive with a pausedAt timestamp and disabledReason: "auto_paused_consecutive_failures" in GET /api/v1/webhooks.

To resume deliveries, reactivate the endpoint in Settings → Developer — reactivation resets the failure counter. Deliveries skipped while paused are not replayed.

Testing webhooks locally

To receive webhooks during local development, expose your local server with a tunneling tool:

  • ngrokngrok http 3000 → use the generated https:// URL as your endpoint
  • Cloudflare Tunnelcloudflared tunnel --url localhost:3000
  • webhook.site — paste your webhook.site URL as the endpoint to inspect raw payloads without writing any code

Register the tunnel URL in Settings → Developer → Webhooks, trigger an action in the dashboard (send a report, add a client), and watch the delivery appear.