# Webhooks

> Taseli posts a signed JSON event to your URL when a change waits, runs, is undone or is judged.

Source: https://taseli.co.il/en/developers/webhooks

## 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:

```bash
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"}'
```

JavaScript:

```js
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, `he` by 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>` |

proposal.created, change.applied, change.undone:

```json
{
  "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"
    }
  }
}
```

verdict.recorded:

```json
{
  "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}](https://taseli.co.il/en/developers/reference#get-proposal).

## Verify the signature

1. Read `t` and `v1` from `Taseli-Signature`.
2. Refuse a `t` more than 300 seconds from now. A retry is signed afresh, so a real delivery always passes.
3. Compute HMAC-SHA256 of `<t>.<raw body>`, keyed with the whole secret including `whsec_`, as hex.
4. Compare it to `v1` in constant time.

Sign the raw body, byte for byte. Parsing and re-serializing the JSON changes it.

verify.js:

```js
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);
}
```

A handler (Next.js, Hono, Workers):

```js
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 `2xx` within 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](https://taseli.co.il/en/developers/reference#test-webhook) that gets a `2xx`: same id, same secret. Events from while it was off aren't sent. Catch up with [GET /proposals](https://taseli.co.il/en/developers/reference#list-proposals).

## Duplicates and order

- An event can arrive twice. Drop repeats by `id`, or by `Taseli-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

```bash
curl -X POST https://taseli.co.il/api/v1/webhooks/$WEBHOOK_ID/test \
  -H "Authorization: Bearer $TASELI_KEY"
```

Response 200:

```json
{
  "data": { "delivered": true, "status": 200, "error": null }
}
```

Your receiver gets:

```json
{
  "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.
