Skip to content
Private betaTreebars is letting teams in from the waitlist.Join the waitlist

Webhooks

Treebars posts to your server two ways: a campaign's webhook channel, once per person it reaches, and a project's notices, once per thing that happened. Check each request came from us before you act on it.

What arrives from a channel

A POST (or the method your template names) with a JSON body: the person's identity_id, the campaign_id or journey_id that sent it, your template's own fields, and a message object with the title and body you wrote. If you gave the channel a bearer token, it arrives as Authorization — but only on requests to the channel's own address, never to a URL a single message names.

What arrives as a project notice

Under Settings → Webhooks an admin points a URL at the project's own notices. Each is a POST of one JSON event, the Standard Webhooks envelope: type, timestamp (when it happened), project (id and name) and data. Fields are only ever added; a type whose meaning had to change would arrive under a new name, beside the old one.

  • finding.published — Iris found something worth a look. data: id, kind (such as anomaly), severity (info, notice or high), title, summary and url.
  • budget.threshold — a monthly allowance reached 80% or its limit. data: budget — events, query_scan, ai_tokens, ai_spend or connector_calls — level (80 or 100), message and url. Each threshold is sent once a month; for events, so are passing what the balance can pay for and collection pausing, both at 100 with the message saying which.
  • report_alert.fired — a report alert's condition held on a day, week or month that has closed. Sent once per incident, however long it goes on holding. data: incident_id, alert_id, alert_name, report_id, report_name, condition (above, below, change or anomaly), threshold, direction, severity, granularity, period (the first day of the period judged, UTC), value, baseline, message and url.
  • report_alert.resolved — the condition no longer holds. The same fields; incident_id matches the report_alert.fired it closes.
  • webhook.test — somebody pressed Send test. Nothing happened in the project.
finding.publishedjson
{
  "type": "finding.published",
  "timestamp": "2026-09-26T09:00:00.000Z",
  "project": { "id": "deec0000-0000-4000-8000-000000000003", "name": "Demo App" },
  "data": {
    "id": "7f3a91c2-4b8e-4d15-9c60-2ae5b18d4f77",
    "kind": "anomaly",
    "severity": "high",
    "title": "Purchases fell 38%",
    "summary": "Down from 212 to 131 against the same day last week.",
    "url": "https://app.treebars.com/"
  }
}

Answer with any 2xx. A 5xx, 429 or 408 — or no answer within ten seconds — is tried again, backing off for up to about seven hours, and honouring your Retry-After. Any other 4xx is taken as final. A URL that has answered nothing but failures for three days is turned off, and the dashboard says why.

A project notice keeps one webhook-id across every attempt, so dedupe on it. Order is not guaranteed; use timestamp.

Checking it came from us

Every project notice, and every request from a channel with a signing secret, carries three headers, the Standard Webhooks scheme, so any of its libraries can check it too:

  • webhook-id — the message's id: one per attempt from a channel, one per event from a project notice.
  • webhook-timestamp — when it was sent, in seconds.
  • webhook-signature — v1, and a base64 HMAC-SHA256 of id.timestamp.body, keyed by your secret.
Your serverts
import { createHmac, timingSafeEqual } from 'node:crypto';

// `body` is the raw request body, exactly as it arrived — before any JSON parsing.
function fromTreebars(body: string, headers: Headers, secret: string): boolean {
  const id = headers.get('webhook-id');
  const timestamp = headers.get('webhook-timestamp');
  const signatures = headers.get('webhook-signature');
  if (!id || !timestamp || !signatures) return false;

  // Older than five minutes: a replay, or a clock a long way off.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();
  return signatures.split(' ').some((entry) => {
    const [version, value] = entry.split(',');
    const given = Buffer.from(value ?? '', 'base64');
    return version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
Check the body as it arrived. Parsing the JSON and serialising it again changes the bytes, and the signature is over the bytes.

Turning it on

In the dashboard, open Channels, then the webhook channel, and choose Reveal secret under Signing secret. It asks a project admin to confirm it is them, and the first reveal turns signing on: every request from then on carries the headers. Keep the secret on your server.

A project notice is signed from the start: the secret is made with the webhook. Open Settings → Webhooks and choose Reveal signing secret on the webhook to copy it.

A retry is a new attempt with a new webhook-id. If you must not act twice, dedupe on your own fields — the campaign or journey, and the person.

Rotating the secret

Rotate, beside the secret, replaces it at once: every request after that is signed with the new one only, so update your server straight away — requests fail your check until you do.