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 asanomaly),severity(info,noticeorhigh),title,summaryandurl.budget.threshold— a monthly allowance reached 80% or its limit.data:budget—events,query_scan,ai_tokens,ai_spendorconnector_calls—level(80 or 100),messageandurl. 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 themessagesaying 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,changeoranomaly),threshold,direction,severity,granularity,period(the first day of the period judged, UTC),value,baseline,messageandurl.report_alert.resolved— the condition no longer holds. The same fields;incident_idmatches thereport_alert.firedit closes.webhook.test— somebody pressed Send test. Nothing happened in the project.
{
"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.
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 ofid.timestamp.body, keyed by your secret.
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);
});
}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.
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.