Skip to content

Webhooks

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

Events

EventWhenData
proposal.createdTaseli prepared a change. It waits for the owner, or ran at once if the owner's autonomy setting allows it.data.proposal
change.appliedAn approved change ran in the ad account.data.proposal
change.undoneA change was undone, by its 7-day watch or by a person.data.proposal
verdict.recordedTaseli judged how a change, budget step or creative test went.data.verdict
webhook.testYou 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"}'

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-Typeapplication/json
User-AgentTaseli-Webhooks/1
Taseli-EventThe event, for example change.applied.
Taseli-DeliveryThe delivery's id: the same on every retry.
Taseli-Signaturet=<unix seconds>,v1=<hex>
proposal.created, change.applied, change.undone
{
  "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
{
  "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

  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
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)
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 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 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
keyThe key that made it was revoked or expired.
memberThe 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
curl -X POST https://taseli.co.il/api/v1/webhooks/$WEBHOOK_ID/test \
  -H "Authorization: Bearer $TASELI_KEY"
Response 200
{
  "data": { "delivered": true, "status": 200, "error": null }
}
Your receiver gets
{
  "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.