Webhooks
Taseli posts a signed JSON event to your URL when a change waits, runs, is undone or is judged.
Events
| Event | When | Data |
|---|---|---|
proposal.created | Taseli prepared a change. It waits for the owner, or ran at once if the owner's autonomy setting allows it. | data.proposal |
change.applied | An approved change ran in the ad account. | data.proposal |
change.undone | A change was undone, by its 7-day watch or by a person. | data.proposal |
verdict.recorded | Taseli judged how a change, budget step or creative test went. | data.verdict |
webhook.test | You asked for a test. Sent whatever the webhook's events. | data.message |
Events report; they can't approve anything. An event's data.proposal.url is where the owner acts.
Create one
Owners and admins, up to 5 per workspace: in Settings › Advanced, or with a personal key that isn't read-only.
curl -X POST https://taseli.co.il/api/v1/webhooks \
-H "Authorization: Bearer $TASELI_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://crm.example.com/hooks/taseli","events":["proposal.created"],"language":"en"}'const res = await fetch("https://taseli.co.il/api/v1/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TASELI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://crm.example.com/hooks/taseli",
events: ["proposal.created"],
language: "en",
}),
});
const { data } = await res.json();
// Store data.secret now. It isn't shown again.The 201 response holds secret, once. It can't be shown again; to replace it, delete the webhook and create a new one.
url:https, default port, a host name that resolves to public addresses.events: leave it out for all four.language: of titles and texts in the events,heby default.
What you receive
A POST with a JSON body and these headers.
| Header | |
|---|---|
Content-Type | application/json |
User-Agent | Taseli-Webhooks/1 |
Taseli-Event | The event, for example change.applied. |
Taseli-Delivery | The delivery's id: the same on every retry. |
Taseli-Signature | t=<unix seconds>,v1=<hex> |
{
"id": "change.applied:0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90:4f1d2a9c",
"type": "change.applied",
"createdAt": "2026-10-20T08:12:00.000Z",
"apiVersion": "v1",
"workspace": {
"id": "0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90",
"slug": "dan-plumbing",
"name": "Dan Plumbing"
},
"data": {
"proposal": {
"id": "0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90",
"number": 214,
"status": "monitoring",
"kind": "negative_keywords_add",
"source": "audit",
"title": "Block 12 searches that spend without leads",
"why": "They took ₪640 in 28 days with no lead.",
"monthlyValue": 680,
"currency": "ILS",
"risk": "low",
"campaign": "Emergency plumber",
"account": {
"id": "0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90",
"name": "Dan Plumbing Search",
"platform": "google"
},
"createdAt": "2026-10-20T08:12:00.000Z",
"executedAt": "2026-10-20T08:12:00.000Z",
"rolledBackAt": null,
"decidedVia": "app",
"url": "https://taseli.co.il/en/app/dan-plumbing/proposals/0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90"
}
}
}{
"id": "verdict.recorded:0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90:4f1d2a9c",
"type": "verdict.recorded",
"createdAt": "2026-10-20T08:12:00.000Z",
"apiVersion": "v1",
"workspace": {
"id": "0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90",
"slug": "dan-plumbing",
"name": "Dan Plumbing"
},
"data": {
"verdict": {
"id": "0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90",
"outcome": "kept",
"kind": "budget_change",
"source": "guardrail",
"platform": "google",
"account": {
"id": "0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90",
"name": "Dan Plumbing Search"
},
"effect": {
"metric": "cpa",
"before": 92,
"after": 78,
"pct": -15.2,
"windowDays": 7
},
"proposalId": "0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90",
"decidedAt": "2026-10-20T08:12:00.000Z"
}
}
}Bodies carry what the owner sees: titles, reasons, worth, state and links. Never tokens, contact details or raw operations. status is the proposal's state when the delivery was sent, which can be past the event: read type for what happened. For the full change, call GET /proposals/{id}.
Verify the signature
- Read
tandv1fromTaseli-Signature. - Refuse a
tmore than 300 seconds from now. A retry is signed afresh, so a real delivery always passes. - Compute HMAC-SHA256 of
<t>.<raw body>, keyed with the whole secret includingwhsec_, as hex. - Compare it to
v1in constant time.
Sign the raw body, byte for byte. Parsing and re-serializing the JSON changes it.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header, toleranceS = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
const want = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"));
const got = Buffer.from(parts.v1 ?? "");
return want.length === got.length && timingSafeEqual(want, got);
}export async function POST(req) {
const body = await req.text(); // the raw body, before any JSON parsing
const signature = req.headers.get("taseli-signature") ?? "";
if (!verify(process.env.TASELI_WEBHOOK_SECRET, body, signature)) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(body);
if (await alreadyHandled(event.id)) return new Response(null, { status: 200 });
if (event.type === "proposal.created") {
await notifyTeam(event.data.proposal.title, event.data.proposal.url);
}
return new Response(null, { status: 200 });
}Answer and retries
- Answer any
2xxwithin 10 seconds. Do slow work after you answer. - A redirect, a timeout or any other status is a failed attempt. Redirects aren't followed.
- A failed event is tried 5 times in all, about 2, 4, 8 and 16 minutes apart, then dropped.
- 25 failed attempts in a row switch the webhook off (
active: false,pausedReason: null). One success resets the count.
To switch it back on, fix the receiver and send a test that gets a 2xx: same id, same secret. Events from while it was off aren't sent. Catch up with GET /proposals.
Duplicates and order
- An event can arrive twice. Drop repeats by
id, or byTaseli-Delivery, which is the same value. - Order isn't guaranteed. Sort by
createdAt, the moment it happened, which is the same on every retry.
When a webhook pauses
A webhook lives as long as what made it. It pauses, and pausedReason says why:
| `pausedReason` | Why |
|---|---|
key | The key that made it was revoked or expired. |
member | The person who made it is no longer an owner or admin. |
A paused webhook stays listed and a test doesn't revive it. Delete it and create a new one.
Test it
curl -X POST https://taseli.co.il/api/v1/webhooks/$WEBHOOK_ID/test \
-H "Authorization: Bearer $TASELI_KEY"{
"data": { "delivered": true, "status": 200, "error": null }
}{
"id": "7c1e0d2a-93b4-4f6e-8a15-6e2b9d4c3f01",
"type": "webhook.test",
"createdAt": "2026-10-20T08:12:00.000Z",
"apiVersion": "v1",
"workspace": {
"id": "0b6f3c1e-7a42-4c55-9b1d-2f6e8a1c4d90",
"slug": "dan-plumbing",
"name": "Dan Plumbing"
},
"data": {
"message": "A test from Taseli. Real events look the same, with their own type and data."
}
}Limits
- A body is at most 256 KB.
- Zapier and Make: a catch-hook URL works as
url. They can't check the signature without a code step, so keep the URL private.