Webhooks
Receive real-time HTTP notifications when things happen in your firm — reports sent, PDFs generated, clients updated, and more.
How it works
- Register an HTTPS endpoint (Settings → Developer → Register endpoint).
- Select which events you want to receive.
- Streamline POSTs a signed JSON payload to your URL when a matching event fires.
- Verify the signature, process the event, return
2xx. - 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
| Event | When it fires | Key data fields |
|---|---|---|
report.sent | A report was emailed to a client | { reportId, clientId, sentToEmail, sentAt } |
report.generated | PDF generation completed successfully | { reportId, downloadUrl } |
client.created | A new client was added to the firm | { clientId, displayName, dataSource } |
client.updated | A 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 "", 200crypto.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:
| Attempt | Delay after previous failure |
|---|---|
| 1 (initial) | — |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| Dead | No 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:
- ngrok —
ngrok http 3000→ use the generatedhttps://URL as your endpoint - Cloudflare Tunnel —
cloudflared 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.